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/1113] 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/1113] 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/1113] 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/1113] 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/1113] 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/1113] 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/1113] 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/1113] 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/1113] 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/1113] 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/1113] 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/1113] [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/1113] 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/1113] 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 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 0015/1113] 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 0016/1113] 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 0017/1113] 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 9650727515df49c51a193ffd71a255d7db2bc4ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 7 Jul 2025 08:32:58 -0500 Subject: [PATCH 0018/1113] 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 0019/1113] 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 0020/1113] 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 0021/1113] 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 0022/1113] 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 0023/1113] 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 0024/1113] 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 0025/1113] 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 0026/1113] 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 0027/1113] 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 0028/1113] 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 0029/1113] 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 0030/1113] 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 0031/1113] 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 0032/1113] 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 0033/1113] 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 0034/1113] 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 0035/1113] 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 0036/1113] 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 0037/1113] 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 0038/1113] 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 0039/1113] 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 0040/1113] 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 0041/1113] 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 0042/1113] 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 0043/1113] 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 0044/1113] 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 0045/1113] 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 0046/1113] 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 0047/1113] 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 0048/1113] 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 0049/1113] 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 0050/1113] 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 0051/1113] 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 0052/1113] 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 0053/1113] 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 0054/1113] 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 0055/1113] 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 0056/1113] 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 0057/1113] 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 5b29d6bbdfbf46306b74696d0bf40c7226a76dc9 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 15 Jul 2025 17:25:22 +0200 Subject: [PATCH 0058/1113] 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 0059/1113] 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 0060/1113] 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 0061/1113] 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 0062/1113] 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 0063/1113] 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 0064/1113] 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 0065/1113] 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 0066/1113] 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 0067/1113] 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 0068/1113] 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 0069/1113] 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 0070/1113] 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 0071/1113] 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 0072/1113] 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 0073/1113] 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 0074/1113] 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 0075/1113] 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 0076/1113] 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 0077/1113] 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 0078/1113] 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 0079/1113] 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 0080/1113] 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 0081/1113] 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 0082/1113] 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 0083/1113] 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 0084/1113] 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 0085/1113] 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 0086/1113] 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 0087/1113] 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 0088/1113] 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 0089/1113] 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 0090/1113] 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 0091/1113] 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 0092/1113] 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 0093/1113] 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 0094/1113] 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 0095/1113] 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 0096/1113] 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 0097/1113] 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 0098/1113] 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 0099/1113] 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 0100/1113] 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 0101/1113] 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 0102/1113] 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 0103/1113] 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 0104/1113] 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 0105/1113] 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 0106/1113] 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 0107/1113] 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 0108/1113] 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 0109/1113] 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 0110/1113] 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 0111/1113] 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 0112/1113] 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 0113/1113] 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 0114/1113] 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 0115/1113] 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 0116/1113] 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 0117/1113] 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 0118/1113] 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 0119/1113] 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 0120/1113] 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 0121/1113] 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 0122/1113] 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 0123/1113] 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 0124/1113] 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 0125/1113] 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 0126/1113] 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 0127/1113] 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 0128/1113] 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 0129/1113] 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 0130/1113] 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 0131/1113] 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 0132/1113] 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 0133/1113] 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 0134/1113] 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 0135/1113] 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 0136/1113] 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 0137/1113] 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 0138/1113] 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 0139/1113] 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 0140/1113] 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 0141/1113] 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 0142/1113] 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 0143/1113] 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 0144/1113] 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 0145/1113] 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 0146/1113] 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 0147/1113] 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 0148/1113] 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 0149/1113] 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 0150/1113] 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 0151/1113] 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 0152/1113] 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 0153/1113] 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 0154/1113] 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 0155/1113] 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 0156/1113] 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 0157/1113] 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 0158/1113] 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 0159/1113] 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 0160/1113] 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 0161/1113] 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 0162/1113] 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 0163/1113] 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 0164/1113] 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 0165/1113] 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 0166/1113] 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 0167/1113] 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 0168/1113] 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 0169/1113] 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 0170/1113] 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 0171/1113] 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 0172/1113] 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 0173/1113] 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 0174/1113] 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 0175/1113] 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 0176/1113] 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 0177/1113] 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 0178/1113] 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 0179/1113] 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 0180/1113] 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 0181/1113] 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 0182/1113] 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 0183/1113] 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 0184/1113] 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 0185/1113] 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 0186/1113] 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 0187/1113] 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 0188/1113] 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 0189/1113] 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 0190/1113] 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 0191/1113] 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 0192/1113] 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 0193/1113] 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 0194/1113] 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 0195/1113] 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 0196/1113] 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 0197/1113] 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 0198/1113] 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 0199/1113] 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 0200/1113] 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 0201/1113] 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 0202/1113] 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 0203/1113] 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 0204/1113] 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 0205/1113] 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 0206/1113] 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 0207/1113] 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 0208/1113] 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 0209/1113] 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 0210/1113] 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 0211/1113] 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 0212/1113] 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 0213/1113] 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 0214/1113] 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 0215/1113] 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 0216/1113] 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 0217/1113] 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 0218/1113] 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 0219/1113] 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 0220/1113] 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 0221/1113] 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 0222/1113] 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 0223/1113] 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 0224/1113] 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 0225/1113] 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 0226/1113] 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 0227/1113] 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 0228/1113] 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 0229/1113] 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 0230/1113] 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 0231/1113] 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 0232/1113] 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 0233/1113] 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 0234/1113] 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 0235/1113] 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 0236/1113] 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 0237/1113] 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 0238/1113] 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 0239/1113] 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 0240/1113] 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 0241/1113] 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 0242/1113] 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 0243/1113] 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 0244/1113] 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 0245/1113] 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 0246/1113] 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 0247/1113] 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 0248/1113] 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 0249/1113] 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 0250/1113] 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 0251/1113] 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 0252/1113] 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 0253/1113] 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 0254/1113] 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 0255/1113] 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 0256/1113] 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 0257/1113] 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 0258/1113] 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 0259/1113] 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 0260/1113] 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 0261/1113] 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 0262/1113] 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 0263/1113] 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 0264/1113] 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 0265/1113] 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 0266/1113] 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 0267/1113] 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 0268/1113] 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 0269/1113] 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 0270/1113] 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 0271/1113] 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 0272/1113] 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 0273/1113] 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 0274/1113] 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 0275/1113] 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 0276/1113] 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 0277/1113] 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 0278/1113] 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 0279/1113] 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 0280/1113] 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 0281/1113] 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 0282/1113] 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 0283/1113] 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 0284/1113] 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 0285/1113] 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 0286/1113] 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 0287/1113] 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 0288/1113] 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 0289/1113] 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 0290/1113] 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 0291/1113] 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 0292/1113] 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 0293/1113] 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 0294/1113] 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 0295/1113] 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 0296/1113] 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 0297/1113] 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 0298/1113] 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 0299/1113] 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 0300/1113] 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 0301/1113] 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 0302/1113] 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 0303/1113] 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 0304/1113] 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 0305/1113] 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 0306/1113] 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 0307/1113] 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 0308/1113] 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 0309/1113] 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 0310/1113] 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 0311/1113] 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 0312/1113] 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 0313/1113] 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 0314/1113] 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 0315/1113] 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 0316/1113] 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 0317/1113] 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 0318/1113] 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 0319/1113] 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 0320/1113] 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 0321/1113] 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 0322/1113] 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 0323/1113] 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 0324/1113] 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 0325/1113] 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 0326/1113] 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 0327/1113] 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 0328/1113] 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 0329/1113] 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 0330/1113] 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 0331/1113] 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 0332/1113] 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 0333/1113] 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 0334/1113] 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 0335/1113] 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 0336/1113] 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 0337/1113] 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 0338/1113] 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 0339/1113] 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 0340/1113] 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 0341/1113] 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 0342/1113] 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 0343/1113] 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 0344/1113] 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 0345/1113] 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 0346/1113] 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 0347/1113] 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 0348/1113] 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 0349/1113] 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 0350/1113] 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 0351/1113] 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 0352/1113] 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 0353/1113] 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 0354/1113] 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 0355/1113] 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 0356/1113] 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 0357/1113] 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 0358/1113] 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 0359/1113] 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 0360/1113] 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 0361/1113] 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 0362/1113] 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 0363/1113] 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 0364/1113] 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 0365/1113] 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 0366/1113] 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 0367/1113] 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 0368/1113] 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 0369/1113] 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 0370/1113] 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 0371/1113] 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 0372/1113] 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 0373/1113] 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 0374/1113] 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 0375/1113] 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 0376/1113] 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 0377/1113] 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 0378/1113] 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 0379/1113] 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 0380/1113] 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 0381/1113] 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 0382/1113] 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 0383/1113] 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 0384/1113] 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 0385/1113] 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 0386/1113] 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 0387/1113] 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 0388/1113] 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 0389/1113] 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 0390/1113] 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 0391/1113] 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 0392/1113] 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 0393/1113] 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 0394/1113] 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 0395/1113] 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 0396/1113] 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 0397/1113] 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 0398/1113] 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 0399/1113] 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 0400/1113] 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 0401/1113] 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 0402/1113] 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 0403/1113] 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 0404/1113] 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 0405/1113] 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 0406/1113] 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 0407/1113] 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 0408/1113] 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 0409/1113] 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 0410/1113] 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 0411/1113] 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 0412/1113] 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 0413/1113] 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 0414/1113] 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 0415/1113] 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 0416/1113] 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 0417/1113] 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 0418/1113] 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 0419/1113] 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 0420/1113] 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 0421/1113] 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 0422/1113] 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 0423/1113] 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 0424/1113] 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 0425/1113] 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 0426/1113] 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 0427/1113] 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 0428/1113] 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 0429/1113] 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 0430/1113] 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 0431/1113] 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 0432/1113] 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 0433/1113] 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 0434/1113] 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 0435/1113] 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 0436/1113] 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 0437/1113] 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 0438/1113] 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 0439/1113] 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 0440/1113] 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 0441/1113] 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 0442/1113] 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 0443/1113] 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 0444/1113] 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 0445/1113] 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 0446/1113] 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 0447/1113] 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 0448/1113] 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 0449/1113] 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 0450/1113] 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 0451/1113] 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 0452/1113] 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 0453/1113] 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 0454/1113] 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 0455/1113] 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 0456/1113] 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 0457/1113] 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 0458/1113] 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 0459/1113] 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 0460/1113] 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 0461/1113] 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 0462/1113] 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 0463/1113] 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 0464/1113] 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 0465/1113] 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 0466/1113] 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 0467/1113] 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 0468/1113] 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 0469/1113] 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 0470/1113] 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 0471/1113] 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 0472/1113] 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 0473/1113] 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 0474/1113] 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 0475/1113] 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 0476/1113] 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 0477/1113] 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 0478/1113] 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 0479/1113] 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 0480/1113] 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 0481/1113] 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 0482/1113] 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 0483/1113] 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 0484/1113] 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 0485/1113] 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 0486/1113] 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 0487/1113] 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 0488/1113] 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 0489/1113] 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 0490/1113] 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 0491/1113] 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 0492/1113] 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 0493/1113] 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 0494/1113] 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 0495/1113] 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 0496/1113] 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 0497/1113] 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 0498/1113] 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 0499/1113] 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 0500/1113] 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 0501/1113] 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 0502/1113] 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 0503/1113] 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 0504/1113] 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 0505/1113] 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 0506/1113] 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 0507/1113] 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 0508/1113] 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 0509/1113] 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 0510/1113] 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 0511/1113] 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 0512/1113] 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 0513/1113] 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 0514/1113] 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 0515/1113] 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 0516/1113] 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 0517/1113] 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 0518/1113] 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 0519/1113] 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 0520/1113] 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 0521/1113] 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 0522/1113] 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 0523/1113] 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 0524/1113] 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 0525/1113] 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 0526/1113] 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 0527/1113] 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 0528/1113] 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 0529/1113] 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 0530/1113] 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 0531/1113] 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 0532/1113] 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 0533/1113] 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 0534/1113] 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 0535/1113] 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 0536/1113] 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 0537/1113] 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 0538/1113] 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 0539/1113] 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 0540/1113] 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 0541/1113] 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 0542/1113] 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 0543/1113] 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 0544/1113] 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 0545/1113] 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 0546/1113] 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 0547/1113] 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 0548/1113] 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 0549/1113] 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 0550/1113] 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 0551/1113] 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 0552/1113] 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 0553/1113] 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 0554/1113] 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 0555/1113] 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 0556/1113] 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 0557/1113] 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 0558/1113] 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 0559/1113] 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 0560/1113] 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 0561/1113] 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 0562/1113] 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 0563/1113] 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 0564/1113] 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 0565/1113] 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 0566/1113] 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 0567/1113] 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 0568/1113] 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 0569/1113] 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 0570/1113] 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 0571/1113] 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 0572/1113] 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 0573/1113] 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 0574/1113] 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 0575/1113] 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 0576/1113] 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 0577/1113] 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 0578/1113] 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 0579/1113] 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 0580/1113] 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 0581/1113] 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 0582/1113] 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 0583/1113] 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 0584/1113] 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 0585/1113] 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 0586/1113] 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 0587/1113] 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 0588/1113] 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 0589/1113] 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 0590/1113] 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 0591/1113] 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 0592/1113] 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 0593/1113] 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 0594/1113] 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 0595/1113] 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 0596/1113] 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 0597/1113] 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 0598/1113] 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 0599/1113] 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 0600/1113] 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 0601/1113] 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 0602/1113] 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 0603/1113] 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 0604/1113] 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 0605/1113] 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 0606/1113] 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 0607/1113] 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 0608/1113] 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 0609/1113] 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 0610/1113] 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 0611/1113] 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 0612/1113] 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 0613/1113] 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 0614/1113] 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 0615/1113] 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 0616/1113] 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 0617/1113] 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 0618/1113] 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 0619/1113] 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 0620/1113] 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 0621/1113] 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 0622/1113] 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 0623/1113] 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 0624/1113] 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 0625/1113] 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 0626/1113] 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 0627/1113] 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 0628/1113] 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 0629/1113] 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 0630/1113] 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 0631/1113] 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 0632/1113] 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 0633/1113] 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 0634/1113] 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 0635/1113] 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 0636/1113] 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 0637/1113] 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 0638/1113] 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 0639/1113] 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 0640/1113] 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 0641/1113] 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 0642/1113] 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 0643/1113] 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 0644/1113] 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 0645/1113] 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 0646/1113] 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 0647/1113] 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 0648/1113] 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 0649/1113] 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 0650/1113] 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 0651/1113] 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 0652/1113] 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 0653/1113] 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 0654/1113] 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 0655/1113] 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 0656/1113] 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 0657/1113] 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 0658/1113] 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 0659/1113] 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 0660/1113] 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 0661/1113] 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 0662/1113] 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 0663/1113] 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 0664/1113] 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 0665/1113] 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 0666/1113] 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 0667/1113] 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 0668/1113] 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 0669/1113] 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 0670/1113] 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 0671/1113] 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 0672/1113] 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 0673/1113] 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 0674/1113] 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 0675/1113] 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 0676/1113] 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 0677/1113] 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 0678/1113] 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 0679/1113] 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 0680/1113] 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 0681/1113] 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 0682/1113] 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 0683/1113] 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 0684/1113] 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 0685/1113] 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 0686/1113] 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 0687/1113] 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 0688/1113] 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 0689/1113] 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 0690/1113] 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 0691/1113] 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 0692/1113] 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 0693/1113] 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 0694/1113] 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 0695/1113] 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 0696/1113] 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 0697/1113] 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 0698/1113] 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 0699/1113] 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 0700/1113] 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 0701/1113] 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 0702/1113] 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 0703/1113] 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 0704/1113] 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 0705/1113] 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 0706/1113] 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 0707/1113] 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 0708/1113] 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 0709/1113] 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 0710/1113] 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 0711/1113] 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 0712/1113] 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 0713/1113] 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 0714/1113] 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 0715/1113] 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 0716/1113] 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 0717/1113] 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 0718/1113] 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 0719/1113] 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 0720/1113] 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 0721/1113] 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 0722/1113] 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 0723/1113] 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 0724/1113] 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 0725/1113] 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 0726/1113] 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 0727/1113] 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 0728/1113] 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 0729/1113] 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 0730/1113] 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 0731/1113] 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 0732/1113] 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 0733/1113] 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 0734/1113] 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 0735/1113] 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 0736/1113] 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 0737/1113] 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 0738/1113] 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 0739/1113] 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 0740/1113] 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 0741/1113] 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 0742/1113] 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 0743/1113] 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 0744/1113] 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 0745/1113] 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 0746/1113] 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 0747/1113] 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 0748/1113] 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 0749/1113] 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 0750/1113] 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 0751/1113] 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 0752/1113] 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 0753/1113] 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 0754/1113] 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 0755/1113] 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 0756/1113] 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 0757/1113] 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 0758/1113] 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 0759/1113] 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 0760/1113] 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 0761/1113] 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 0762/1113] 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 0763/1113] 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 0764/1113] 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 0765/1113] 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 0766/1113] 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 0767/1113] 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 0768/1113] 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 0769/1113] 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 0770/1113] 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 0771/1113] 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 0772/1113] 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 0773/1113] 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 0774/1113] 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 0775/1113] 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 0776/1113] 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 0777/1113] 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 0778/1113] 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 0779/1113] 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 0780/1113] 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 0781/1113] 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 0782/1113] 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 0783/1113] 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 0784/1113] 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 0785/1113] 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 0786/1113] 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 0787/1113] 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 0788/1113] 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 0789/1113] 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 0790/1113] 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 0791/1113] 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 0792/1113] 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 0793/1113] 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 0794/1113] 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 0795/1113] 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 0796/1113] 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 0797/1113] 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 0798/1113] 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 0799/1113] 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 0800/1113] 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 0801/1113] 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 0802/1113] 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 0803/1113] 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 0804/1113] 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 0805/1113] 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 0806/1113] 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 0807/1113] 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 0808/1113] 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 0809/1113] 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 0810/1113] 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 0811/1113] 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 0812/1113] 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 0813/1113] 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 0814/1113] 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 0815/1113] 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 0816/1113] 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 0817/1113] 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 0818/1113] 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 0819/1113] 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 0820/1113] 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 0821/1113] 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 0822/1113] 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 0823/1113] 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 0824/1113] 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 0825/1113] 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 0826/1113] 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 0827/1113] 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 0828/1113] 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 0829/1113] 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 0830/1113] 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 0831/1113] 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 0832/1113] 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 0833/1113] 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 0834/1113] 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 0835/1113] 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 0836/1113] 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 0837/1113] 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 0838/1113] 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 0839/1113] 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 0840/1113] 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 0841/1113] 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 0842/1113] 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 0843/1113] 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 0844/1113] 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 0845/1113] 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 0846/1113] 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 0847/1113] 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 0848/1113] 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 0849/1113] 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 0850/1113] 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 0851/1113] 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 0852/1113] 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 0853/1113] 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 0854/1113] 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 0855/1113] 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 0856/1113] 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 0857/1113] 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 0858/1113] 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 0859/1113] 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 0860/1113] 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 0861/1113] 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 0862/1113] 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 0863/1113] 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 0864/1113] 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 0865/1113] 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 0866/1113] 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 0867/1113] 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 0868/1113] 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 0869/1113] 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 0870/1113] 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 0871/1113] 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 0872/1113] 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 0873/1113] 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 0874/1113] 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 0875/1113] 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 0876/1113] 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 0877/1113] 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 0878/1113] 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 0879/1113] 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 0880/1113] 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 0881/1113] 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 0882/1113] 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 0883/1113] 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 0884/1113] 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 0885/1113] 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 0886/1113] 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 0887/1113] 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 0888/1113] 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 0889/1113] 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 0890/1113] 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 0891/1113] 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 0892/1113] 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 0893/1113] 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 0894/1113] 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 0895/1113] =?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 0896/1113] 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 0897/1113] 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 0898/1113] 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 0899/1113] 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 0900/1113] 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 0901/1113] 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 0902/1113] 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 0903/1113] 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 0904/1113] 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 0905/1113] 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 0906/1113] 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 0907/1113] 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 0908/1113] 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 0909/1113] 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 0910/1113] 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 0911/1113] 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 0912/1113] 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 0913/1113] 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 0914/1113] 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 0915/1113] 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 0916/1113] 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 0917/1113] 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 0918/1113] 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 0919/1113] 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 0920/1113] 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 0921/1113] 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 0922/1113] 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 0923/1113] 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 0924/1113] 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 0925/1113] 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", From 93c30f1b59cf59c6357a8fc1112789d04419975e Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 11 Aug 2025 23:25:51 +0200 Subject: [PATCH 0926/1113] Add sensor platform to Sleep as Android (#150440) --- .../components/sleep_as_android/__init__.py | 2 +- .../components/sleep_as_android/const.py | 2 + .../components/sleep_as_android/entity.py | 14 ++ .../components/sleep_as_android/event.py | 10 +- .../components/sleep_as_android/sensor.py | 96 ++++++++++++++ .../components/sleep_as_android/strings.json | 11 ++ .../snapshots/test_sensor.ambr | 98 ++++++++++++++ .../components/sleep_as_android/test_event.py | 14 +- .../sleep_as_android/test_sensor.py | 124 ++++++++++++++++++ 9 files changed, 360 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/sleep_as_android/sensor.py create mode 100644 tests/components/sleep_as_android/snapshots/test_sensor.ambr create mode 100644 tests/components/sleep_as_android/test_sensor.py diff --git a/homeassistant/components/sleep_as_android/__init__.py b/homeassistant/components/sleep_as_android/__init__.py index 09a77504e12..8dd08ba0388 100644 --- a/homeassistant/components/sleep_as_android/__init__.py +++ b/homeassistant/components/sleep_as_android/__init__.py @@ -15,7 +15,7 @@ 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] +PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR] type SleepAsAndroidConfigEntry = ConfigEntry diff --git a/homeassistant/components/sleep_as_android/const.py b/homeassistant/components/sleep_as_android/const.py index 057c326aa86..37cf3f14261 100644 --- a/homeassistant/components/sleep_as_android/const.py +++ b/homeassistant/components/sleep_as_android/const.py @@ -28,3 +28,5 @@ MAP_EVENTS = { "lullaby_stop": "stop", "lullaby_volume_down": "volume_down", } + +ALARM_LABEL_DEFAULT = "alarm" diff --git a/homeassistant/components/sleep_as_android/entity.py b/homeassistant/components/sleep_as_android/entity.py index 5a4008d0cdd..5984bb45efd 100644 --- a/homeassistant/components/sleep_as_android/entity.py +++ b/homeassistant/components/sleep_as_android/entity.py @@ -2,8 +2,11 @@ from __future__ import annotations +from abc import abstractmethod + from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity, EntityDescription from . import SleepAsAndroidConfigEntry @@ -31,3 +34,14 @@ class SleepAsAndroidEntity(Entity): model="Sleep as Android", name=config_entry.title, ) + + @abstractmethod + def _async_handle_event(self, webhook_id: str, data: dict[str, str]) -> None: + """Handle the Sleep as Android event.""" + + 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/event.py b/homeassistant/components/sleep_as_android/event.py index 189accd7601..20a3690a0a5 100644 --- a/homeassistant/components/sleep_as_android/event.py +++ b/homeassistant/components/sleep_as_android/event.py @@ -11,11 +11,10 @@ from homeassistant.components.event import ( 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 .const import ATTR_EVENT, MAP_EVENTS from .entity import SleepAsAndroidEntity PARALLEL_UPDATES = 0 @@ -152,10 +151,3 @@ class SleepAsAndroidEventEntity(SleepAsAndroidEntity, EventEntity): ): 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/sensor.py b/homeassistant/components/sleep_as_android/sensor.py new file mode 100644 index 00000000000..966e851f633 --- /dev/null +++ b/homeassistant/components/sleep_as_android/sensor.py @@ -0,0 +1,96 @@ +"""Sensor platform for Sleep as Android integration.""" + +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum + +from homeassistant.components.sensor import ( + RestoreSensor, + SensorDeviceClass, + SensorEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util + +from . import SleepAsAndroidConfigEntry +from .const import ALARM_LABEL_DEFAULT, ATTR_EVENT, ATTR_VALUE1, ATTR_VALUE2 +from .entity import SleepAsAndroidEntity + +PARALLEL_UPDATES = 0 + + +class SleepAsAndroidSensor(StrEnum): + """Sleep as Android sensors.""" + + NEXT_ALARM = "next_alarm" + ALARM_LABEL = "alarm_label" + + +SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SleepAsAndroidSensor.NEXT_ALARM, + translation_key=SleepAsAndroidSensor.NEXT_ALARM, + device_class=SensorDeviceClass.TIMESTAMP, + ), + SensorEntityDescription( + key=SleepAsAndroidSensor.ALARM_LABEL, + translation_key=SleepAsAndroidSensor.ALARM_LABEL, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SleepAsAndroidConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + + async_add_entities( + SleepAsAndroidSensorEntity(config_entry, description) + for description in SENSOR_DESCRIPTIONS + ) + + +class SleepAsAndroidSensorEntity(SleepAsAndroidEntity, RestoreSensor): + """A sensor entity.""" + + entity_description: SensorEntityDescription + + @callback + def _async_handle_event(self, webhook_id: str, data: dict[str, str]) -> None: + """Handle the Sleep as Android event.""" + + if webhook_id == self.webhook_id and data[ATTR_EVENT] in ( + "alarm_snooze_clicked", + "alarm_snooze_canceled", + "alarm_alert_start", + "alarm_alert_dismiss", + "alarm_skip_next", + "show_skip_next_alarm", + "alarm_rescheduled", + ): + if ( + self.entity_description.key is SleepAsAndroidSensor.NEXT_ALARM + and (alarm_time := data.get(ATTR_VALUE1)) + and alarm_time.isnumeric() + ): + self._attr_native_value = datetime.fromtimestamp( + int(alarm_time) / 1000, tz=dt_util.get_default_time_zone() + ) + if self.entity_description.key is SleepAsAndroidSensor.ALARM_LABEL and ( + label := data.get(ATTR_VALUE2, ALARM_LABEL_DEFAULT) + ): + self._attr_native_value = label + + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Restore entity state.""" + state = await self.async_get_last_sensor_data() + if state: + self._attr_native_value = state.native_value + + await super().async_added_to_hass() diff --git a/homeassistant/components/sleep_as_android/strings.json b/homeassistant/components/sleep_as_android/strings.json index 2822961c18e..f36b26e5b58 100644 --- a/homeassistant/components/sleep_as_android/strings.json +++ b/homeassistant/components/sleep_as_android/strings.json @@ -118,6 +118,17 @@ } } } + }, + "sensor": { + "next_alarm": { + "name": "Next alarm" + }, + "alarm_label": { + "name": "Alarm label", + "state": { + "alarm": "Alarm" + } + } } } } diff --git a/tests/components/sleep_as_android/snapshots/test_sensor.ambr b/tests/components/sleep_as_android/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..fb7f7554689 --- /dev/null +++ b/tests/components/sleep_as_android/snapshots/test_sensor.ambr @@ -0,0 +1,98 @@ +# serializer version: 1 +# name: test_setup[sensor.sleep_as_android_alarm_label-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.sleep_as_android_alarm_label', + 'has_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 label', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_alarm_label', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.sleep_as_android_alarm_label-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sleep as Android Alarm label', + }), + 'context': , + 'entity_id': 'sensor.sleep_as_android_alarm_label', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'label', + }) +# --- +# name: test_setup[sensor.sleep_as_android_next_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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sleep_as_android_next_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Next alarm', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_next_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.sleep_as_android_next_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Sleep as Android Next alarm', + }), + 'context': , + 'entity_id': 'sensor.sleep_as_android_next_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-02-26T12:21:00+00:00', + }) +# --- diff --git a/tests/components/sleep_as_android/test_event.py b/tests/components/sleep_as_android/test_event.py index 514b3566dd7..4e3a94f919b 100644 --- a/tests/components/sleep_as_android/test_event.py +++ b/tests/components/sleep_as_android/test_event.py @@ -1,13 +1,15 @@ """Test the Sleep as Android event platform.""" +from collections.abc import Generator from http import HTTPStatus +from unittest.mock import patch 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.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -15,6 +17,16 @@ from tests.common import MockConfigEntry, snapshot_platform from tests.typing import ClientSessionGenerator +@pytest.fixture(autouse=True) +def event_only() -> Generator[None]: + """Enable only the event platform.""" + with patch( + "homeassistant.components.sleep_as_android.PLATFORMS", + [Platform.EVENT], + ): + yield + + @freeze_time("2025-01-01T03:30:00.000Z") async def test_setup( hass: HomeAssistant, diff --git a/tests/components/sleep_as_android/test_sensor.py b/tests/components/sleep_as_android/test_sensor.py new file mode 100644 index 00000000000..760df1e0181 --- /dev/null +++ b/tests/components/sleep_as_android/test_sensor.py @@ -0,0 +1,124 @@ +"""Test the Sleep as Android sensor platform.""" + +from collections.abc import Generator +from datetime import datetime +from http import HTTPStatus +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import entity_registry as er + +from tests.common import ( + MockConfigEntry, + mock_restore_cache_with_extra_data, + snapshot_platform, +) +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +def sensor_only() -> Generator[None]: + """Enable only the sensor platform.""" + with patch( + "homeassistant.components.sleep_as_android.PLATFORMS", + [Platform.SENSOR], + ): + yield + + +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of sensor platform.""" + + mock_restore_cache_with_extra_data( + hass, + ( + ( + State( + "sensor.sleep_as_android_next_alarm", + "", + ), + { + "native_value": datetime.fromisoformat("2020-02-26T12:21:00+00:00"), + "native_unit_of_measurement": None, + }, + ), + ( + State( + "sensor.sleep_as_android_alarm_label", + "", + ), + { + "native_value": "label", + "native_unit_of_measurement": None, + }, + ), + ), + ) + 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( + "event", + [ + "alarm_snooze_clicked", + "alarm_snooze_canceled", + "alarm_alert_start", + "alarm_alert_dismiss", + "alarm_skip_next", + "show_skip_next_alarm", + "alarm_rescheduled", + ], +) +async def test_webhook_sensor( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + event: str, +) -> None: + """Test webhook updates sensor.""" + + 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("sensor.sleep_as_android_next_alarm")) + assert state.state == STATE_UNKNOWN + + assert (state := hass.states.get("sensor.sleep_as_android_alarm_label")) + assert state.state == STATE_UNKNOWN + + client = await hass_client_no_auth() + + response = await client.post( + "/api/webhook/webhook_id", + json={ + "event": event, + "value1": "1582719660934", + "value2": "label", + }, + ) + assert response.status == HTTPStatus.NO_CONTENT + + assert (state := hass.states.get("sensor.sleep_as_android_next_alarm")) + assert state.state == "2020-02-26T12:21:00+00:00" + + assert (state := hass.states.get("sensor.sleep_as_android_alarm_label")) + assert state.state == "label" From 5605f5896afedd42df9295b520cd1652b4641e20 Mon Sep 17 00:00:00 2001 From: Wesley Vos <17592840+Wesley-Vos@users.noreply.github.com> Date: Mon, 11 Aug 2025 23:26:27 +0200 Subject: [PATCH 0927/1113] Remove the battery feature from supported features (#150101) --- homeassistant/components/roomba/entity.py | 5 ----- homeassistant/components/roomba/sensor.py | 2 +- homeassistant/components/roomba/vacuum.py | 3 +-- 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/roomba/entity.py b/homeassistant/components/roomba/entity.py index 14c7ac3af3e..eb1b3696102 100644 --- a/homeassistant/components/roomba/entity.py +++ b/homeassistant/components/roomba/entity.py @@ -51,11 +51,6 @@ class IRobotEntity(Entity): """Return the uniqueid of the vacuum cleaner.""" return self.robot_unique_id - @property - def battery_level(self): - """Return the battery level of the vacuum cleaner.""" - return self.vacuum_state.get("batPct") - @property def run_stats(self): """Return the run stats.""" diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index 3a98bedcd94..ae82424ec34 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -35,7 +35,7 @@ SENSORS: list[RoombaSensorEntityDescription] = [ native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda self: self.battery_level, + value_fn=lambda self: self.vacuum_state.get("batPct"), ), RoombaSensorEntityDescription( key="battery_cycles", diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py index 10606814a35..0c24301f2af 100644 --- a/homeassistant/components/roomba/vacuum.py +++ b/homeassistant/components/roomba/vacuum.py @@ -24,8 +24,7 @@ from .entity import IRobotEntity from .models import RoombaData SUPPORT_IROBOT = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.PAUSE + VacuumEntityFeature.PAUSE | VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.START From 6cde5cfdcc90f36c121c16134fc52f6b1749c9a7 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 11 Aug 2025 23:47:07 +0200 Subject: [PATCH 0928/1113] Add diagnostics platform to Sleep as Android (#150447) --- .../sleep_as_android/diagnostics.py | 19 ++++++++++++++ .../sleep_as_android/quality_scale.yaml | 2 +- .../snapshots/test_diagnostics.ambr | 8 ++++++ .../sleep_as_android/test_diagnostics.py | 26 +++++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/sleep_as_android/diagnostics.py create mode 100644 tests/components/sleep_as_android/snapshots/test_diagnostics.ambr create mode 100644 tests/components/sleep_as_android/test_diagnostics.py diff --git a/homeassistant/components/sleep_as_android/diagnostics.py b/homeassistant/components/sleep_as_android/diagnostics.py new file mode 100644 index 00000000000..2f49e818ece --- /dev/null +++ b/homeassistant/components/sleep_as_android/diagnostics.py @@ -0,0 +1,19 @@ +"""Diagnostics platform for Sleep as Android integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import SleepAsAndroidConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: SleepAsAndroidConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return { + "config_entry_data": {"cloudhook": config_entry.data["cloudhook"]}, + } diff --git a/homeassistant/components/sleep_as_android/quality_scale.yaml b/homeassistant/components/sleep_as_android/quality_scale.yaml index 5565f9eb834..acc2d8d11f0 100644 --- a/homeassistant/components/sleep_as_android/quality_scale.yaml +++ b/homeassistant/components/sleep_as_android/quality_scale.yaml @@ -61,7 +61,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: no discovery diff --git a/tests/components/sleep_as_android/snapshots/test_diagnostics.ambr b/tests/components/sleep_as_android/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..c7e391317da --- /dev/null +++ b/tests/components/sleep_as_android/snapshots/test_diagnostics.ambr @@ -0,0 +1,8 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry_data': dict({ + 'cloudhook': False, + }), + }) +# --- diff --git a/tests/components/sleep_as_android/test_diagnostics.py b/tests/components/sleep_as_android/test_diagnostics.py new file mode 100644 index 00000000000..a3e67dafe76 --- /dev/null +++ b/tests/components/sleep_as_android/test_diagnostics.py @@ -0,0 +1,26 @@ +"""Tests for Sleep as Android diagnostics.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + 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 68fbcc8665d89b645199800fcbd4ef8f95bc7780 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 12 Aug 2025 00:50:05 +0200 Subject: [PATCH 0929/1113] Add pymodbus to package constraints (#150419) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/package_constraints.txt | 5 +++++ script/gen_requirements_all.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 165bd547dae..b26cd30f0d6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -215,3 +215,8 @@ 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 + +# pymodbus does not follow SemVer, and it keeps getting +# downgraded or upgraded by custom components +# This ensures all use the same version +pymodbus==3.11.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index a62f2f62bc1..b710fbc31ed 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -241,6 +241,11 @@ 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 + +# pymodbus does not follow SemVer, and it keeps getting +# downgraded or upgraded by custom components +# This ensures all use the same version +pymodbus==3.11.1 """ GENERATED_MESSAGE = ( From a06df2a68023f8e7374933aee3e3fc62a0a0e900 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Tue, 12 Aug 2025 02:39:37 -0400 Subject: [PATCH 0930/1113] Make disk_lifetime issue into a repair (#150140) --- homeassistant/components/hassio/issues.py | 1 + homeassistant/components/hassio/strings.json | 4 ++ tests/components/hassio/test_issues.py | 47 ++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 35f7f48481e..68d01e93beb 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -103,6 +103,7 @@ ISSUE_KEYS_FOR_REPAIRS = { ISSUE_KEY_SYSTEM_DOCKER_CONFIG, ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, + "issue_system_disk_lifetime", } _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 5df197bddcb..393fe480057 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -115,6 +115,10 @@ } } }, + "issue_system_disk_lifetime": { + "title": "Disk lifetime exceeding 90%", + "description": "The data disk has exceeded 90% of its expected lifespan. The disk may soon malfunction which can lead to data loss. You should replace it soon and migrate your data." + }, "unhealthy": { "title": "Unhealthy system - {reason}", "description": "System is currently unhealthy due to {reason}. For troubleshooting information, select Learn more." diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index b0d3920be09..a4ad0a4a004 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -850,3 +850,50 @@ async def test_supervisor_issues_detached_addon_missing( "addon_url": "/hassio/addon/test", }, ) + + +@pytest.mark.usefixtures("all_setup_requests") +async def test_supervisor_issues_disk_lifetime( + hass: HomeAssistant, + supervisor_client: AsyncMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test supervisor issue for disk lifetime nearly exceeded.""" + mock_resolution_info(supervisor_client) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "issue_changed", + "data": { + "uuid": (issue_uuid := uuid4().hex), + "type": "disk_lifetime", + "context": "system", + "reference": None, + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_issue_repair_in_list( + msg["result"]["issues"], + uuid=issue_uuid, + context="system", + type_="disk_lifetime", + fixable=False, + placeholders=None, + ) From c46412ee5be26e09d259a1c41293ef17009d66b2 Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Tue, 12 Aug 2025 09:51:39 +0200 Subject: [PATCH 0931/1113] Bump cookidoo-api to 0.14.0 (#150450) --- homeassistant/components/cookidoo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cookidoo/manifest.json b/homeassistant/components/cookidoo/manifest.json index 5264e47a709..b4cf653f810 100644 --- a/homeassistant/components/cookidoo/manifest.json +++ b/homeassistant/components/cookidoo/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["cookidoo_api"], "quality_scale": "silver", - "requirements": ["cookidoo-api==0.12.2"] + "requirements": ["cookidoo-api==0.14.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5c3747f8658..e5d8b95b89a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -740,7 +740,7 @@ connect-box==0.3.1 construct==2.10.68 # homeassistant.components.cookidoo -cookidoo-api==0.12.2 +cookidoo-api==0.14.0 # homeassistant.components.backup # homeassistant.components.utility_meter diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7fbf37d1df6..a7cac53c700 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -643,7 +643,7 @@ colorthief==0.2.1 construct==2.10.68 # homeassistant.components.cookidoo -cookidoo-api==0.12.2 +cookidoo-api==0.14.0 # homeassistant.components.backup # homeassistant.components.utility_meter From e0a8c9b45845c47158a5c915f1a92efbbb329fcc Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 12 Aug 2025 10:30:38 +0200 Subject: [PATCH 0932/1113] Fix missing sentence-casing in `somfy_mylink` (#150463) --- homeassistant/components/somfy_mylink/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/somfy_mylink/strings.json b/homeassistant/components/somfy_mylink/strings.json index 90489c0ba34..ec501fac302 100644 --- a/homeassistant/components/somfy_mylink/strings.json +++ b/homeassistant/components/somfy_mylink/strings.json @@ -29,13 +29,13 @@ }, "step": { "init": { - "title": "Configure MyLink Options", + "title": "Configure MyLink options", "data": { "target_id": "Configure options for a cover." } }, "target_config": { - "title": "Configure MyLink Cover", + "title": "Configure MyLink cover", "description": "Configure options for `{target_name}`", "data": { "reverse": "Cover is reversed" From f6af524ddff80448279330dcbc7d588b1ca98c23 Mon Sep 17 00:00:00 2001 From: Matrix Date: Tue, 12 Aug 2025 16:42:40 +0800 Subject: [PATCH 0933/1113] Fix YoLink valve state when device running in class A mode (#150456) --- homeassistant/components/yolink/strings.json | 3 +++ homeassistant/components/yolink/valve.py | 14 ++++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index 0eb9de97469..4215031d904 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -47,6 +47,9 @@ "exceptions": { "invalid_config_entry": { "message": "Config entry not found or not loaded!" + }, + "valve_inoperable_currently": { + "message": "The Valve cannot be operated currently." } }, "entity": { diff --git a/homeassistant/components/yolink/valve.py b/homeassistant/components/yolink/valve.py index 06dee8af540..e63488194d0 100644 --- a/homeassistant/components/yolink/valve.py +++ b/homeassistant/components/yolink/valve.py @@ -21,6 +21,7 @@ from homeassistant.components.valve import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DEV_MODEL_WATER_METER_YS5007, DOMAIN @@ -130,6 +131,13 @@ class YoLinkValveEntity(YoLinkEntity, ValveEntity): async def _async_invoke_device(self, state: str) -> None: """Call setState api to change valve state.""" + if ( + self.coordinator.device.is_support_mode_switching() + and self.coordinator.dev_net_type == ATTR_DEVICE_MODEL_A + ): + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="valve_inoperable_currently" + ) if ( self.coordinator.device.device_type == ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER @@ -155,10 +163,4 @@ class YoLinkValveEntity(YoLinkEntity, ValveEntity): @property def available(self) -> bool: """Return true is device is available.""" - if ( - self.coordinator.device.is_support_mode_switching() - and self.coordinator.dev_net_type is not None - ): - # When the device operates in Class A mode, it cannot be controlled. - return self.coordinator.dev_net_type != ATTR_DEVICE_MODEL_A return super().available From 8f5c8caf07212ddeaaa6a0736a9936dcc7b58023 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 12 Aug 2025 10:45:39 +0200 Subject: [PATCH 0934/1113] Add mute switch to Tuya smoke detectors (#150469) --- homeassistant/components/tuya/switch.py | 9 ++ tests/components/tuya/__init__.py | 1 + .../tuya/fixtures/ywbj_rccxox8p.json | 68 ++++++++ .../tuya/snapshots/test_binary_sensor.ambr | 49 ++++++ .../tuya/snapshots/test_sensor.ambr | 152 ++++++++++++++++++ .../tuya/snapshots/test_switch.ambr | 144 +++++++++++++++++ 6 files changed, 423 insertions(+) create mode 100644 tests/components/tuya/fixtures/ywbj_rccxox8p.json diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 81062a092ca..e20923133f9 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -881,6 +881,15 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smoke Detector + # https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952 + "ywbj": ( + SwitchEntityDescription( + key=DPCode.MUFFLING, + translation_key="mute", + entity_category=EntityCategory.CONFIG, + ), + ), # Smart Electricity Meter # https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7 "zndb": ( diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 700ad9116ed..44fe55fbb0b 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -187,6 +187,7 @@ DEVICE_MOCKS = [ "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 + "ywbj_rccxox8p", # https://github.com/orgs/home-assistant/discussions/625 "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 diff --git a/tests/components/tuya/fixtures/ywbj_rccxox8p.json b/tests/components/tuya/fixtures/ywbj_rccxox8p.json new file mode 100644 index 00000000000..45a5e8697f2 --- /dev/null +++ b/tests/components/tuya/fixtures/ywbj_rccxox8p.json @@ -0,0 +1,68 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Smoke Alarm", + "category": "ywbj", + "product_id": "rccxox8p", + "product_name": "Smoke Alarm", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2024-08-05T13:47:04+00:00", + "create_time": "2024-08-05T13:47:04+00:00", + "update_time": "2024-08-05T13:47:04+00:00", + "function": { + "muffling": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "smoke_sensor_status": { + "type": "Enum", + "value": { + "range": ["alarm", "normal"] + } + }, + "smoke_sensor_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 1, + "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 + } + }, + "muffling": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "smoke_sensor_status": "normal", + "smoke_sensor_value": 0, + "battery_state": "low", + "battery_percentage": 100, + "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 cdc009a5d78..c2f246fb9e9 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -1174,6 +1174,55 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[binary_sensor.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.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.p8xoxccrjbwysmoke_sensor_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.smoke_alarm_smoke-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Smoke Alarm Smoke', + }), + 'context': , + 'entity_id': 'binary_sensor.smoke_alarm_smoke', + '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_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 00c170ca93c..375e52f6bbd 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -10098,6 +10098,158 @@ 'state': '97.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.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.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.p8xoxccrjbwybattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smoke_alarm_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Smoke Alarm Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.smoke_alarm_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smoke_alarm_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_alarm_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.p8xoxccrjbwybattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smoke_alarm_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smoke Alarm Battery state', + }), + 'context': , + 'entity_id': 'sensor.smoke_alarm_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smoke_alarm_smoke_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': , + 'entity_id': 'sensor.smoke_alarm_smoke_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': 'Smoke amount', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'smoke_amount', + 'unique_id': 'tuya.p8xoxccrjbwysmoke_sensor_value', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smoke_alarm_smoke_amount-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smoke Alarm Smoke amount', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.smoke_alarm_smoke_amount', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.smoke_detector_upstairs_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 0ab53480c22..bff8c3e392a 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -6389,6 +6389,102 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.smoke_alarm_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.smoke_alarm_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.p8xoxccrjbwymuffling', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.smoke_alarm_mute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smoke Alarm Mute', + }), + 'context': , + 'entity_id': 'switch.smoke_alarm_mute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.smoke_detector_upstairs_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.smoke_detector_upstairs_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.jfydgffzmhjed9fgjbwymuffling', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.smoke_detector_upstairs_mute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': ' Smoke detector upstairs Mute', + }), + 'context': , + 'entity_id': 'switch.smoke_detector_upstairs_mute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.socket3_switch_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7898,6 +7994,54 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.wifi_smoke_alarm_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.wifi_smoke_alarm_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.tvgoe1s3fabebcskjbwymuffling', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.wifi_smoke_alarm_mute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WIFI Smoke alarm Mute', + }), + 'context': , + 'entity_id': 'switch.wifi_smoke_alarm_mute', + '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 db81610983aa2b1cb11efba4fbaeaaf252458b05 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Aug 2025 03:46:53 -0500 Subject: [PATCH 0935/1113] Bump aioesphomeapi to 38.2.1 (#150455) --- homeassistant/components/esphome/manager.py | 10 ++++---- .../components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/conftest.py | 24 ++++++++++++++++--- tests/components/esphome/test_manager.py | 24 ++++++++++++------- 6 files changed, 45 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 4d5de77b1e0..742ed266bf3 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -564,11 +564,11 @@ class ESPHomeManager: ) entry_data.loaded_platforms.add(Platform.ASSIST_SATELLITE) - cli.subscribe_states(entry_data.async_update_state) - cli.subscribe_service_calls(self.async_on_service_call) - cli.subscribe_home_assistant_states( - self.async_on_state_subscription, - self.async_on_state_request, + cli.subscribe_home_assistant_states_and_services( + on_state=entry_data.async_update_state, + on_service_call=self.async_on_service_call, + on_state_sub=self.async_on_state_subscription, + on_state_request=self.async_on_state_request, ) entry_data.async_save_to_store() diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 6bf164aa9bc..fafeecc1304 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.2", + "aioesphomeapi==38.2.1", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.1.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index e5d8b95b89a..4cd27301bec 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.2 +aioesphomeapi==38.2.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a7cac53c700..537ca44d85e 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.2 +aioesphomeapi==38.2.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 9de97bac3eb..35885722d8a 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -517,9 +517,27 @@ async def _mock_generic_device_entry( mock_client.list_entities_services = AsyncMock( return_value=mock_list_entities_services ) - mock_client.subscribe_states = _subscribe_states - mock_client.subscribe_service_calls = _subscribe_service_calls - mock_client.subscribe_home_assistant_states = _subscribe_home_assistant_states + + def _subscribe_home_assistant_states_and_services( + *, + on_state: Callable[[EntityState], None], + on_service_call: Callable[[HomeassistantServiceCall], None], + on_state_sub: Callable[[str, str | None], None], + on_state_request: Callable[[str, str | None], None], + ) -> None: + """Subscribe to states and service calls.""" + mock_device.set_state_callback(on_state) + mock_device.set_service_call_callback(on_service_call) + mock_device.set_home_assistant_state_subscription_callback( + on_state_sub, on_state_request + ) + # Set the initial states + for state in states: + on_state(state) + + mock_client.subscribe_home_assistant_states_and_services = ( + _subscribe_home_assistant_states_and_services + ) mock_client.subscribe_logs = _subscribe_logs try_connect_done = Event() diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index fec957a9560..c29dbad1d37 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -416,10 +416,12 @@ async def test_unique_id_updated_to_mac( entry.add_to_hass(hass) subscribe_done = hass.loop.create_future() - def async_subscribe_states(*args, **kwargs) -> None: + def async_subscribe_home_assistant_states_and_services(*args, **kwargs) -> None: subscribe_done.set_result(None) - mock_client.subscribe_states = async_subscribe_states + mock_client.subscribe_home_assistant_states_and_services = ( + async_subscribe_home_assistant_states_and_services + ) mock_client.device_info = AsyncMock( return_value=DeviceInfo( mac_address="1122334455aa", @@ -447,10 +449,12 @@ async def test_add_missing_bluetooth_mac_address( entry.add_to_hass(hass) subscribe_done = hass.loop.create_future() - def async_subscribe_states(*args, **kwargs) -> None: + def async_subscribe_home_assistant_states_and_services(*args, **kwargs) -> None: subscribe_done.set_result(None) - mock_client.subscribe_states = async_subscribe_states + mock_client.subscribe_home_assistant_states_and_services = ( + async_subscribe_home_assistant_states_and_services + ) mock_client.device_info = AsyncMock( return_value=DeviceInfo( mac_address="1122334455aa", @@ -587,10 +591,12 @@ async def test_name_updated_only_if_mac_matches( entry.add_to_hass(hass) subscribe_done = hass.loop.create_future() - def async_subscribe_states(*args, **kwargs) -> None: + def async_subscribe_home_assistant_states_and_services(*args, **kwargs) -> None: subscribe_done.set_result(None) - mock_client.subscribe_states = async_subscribe_states + mock_client.subscribe_home_assistant_states_and_services = ( + async_subscribe_home_assistant_states_and_services + ) mock_client.device_info = AsyncMock( return_value=DeviceInfo(mac_address="1122334455aa", name="new") ) @@ -622,10 +628,12 @@ async def test_name_updated_only_if_mac_was_unset( entry.add_to_hass(hass) subscribe_done = hass.loop.create_future() - def async_subscribe_states(*args, **kwargs) -> None: + def async_subscribe_home_assistant_states_and_services(*args, **kwargs) -> None: subscribe_done.set_result(None) - mock_client.subscribe_states = async_subscribe_states + mock_client.subscribe_home_assistant_states_and_services = ( + async_subscribe_home_assistant_states_and_services + ) mock_client.device_info = AsyncMock( return_value=DeviceInfo(mac_address="1122334455aa", name="new") ) From 5b232226e99c8a136f32b6bf5ceca2eba16f8b35 Mon Sep 17 00:00:00 2001 From: yufeng Date: Tue, 12 Aug 2025 16:53:08 +0800 Subject: [PATCH 0936/1113] Add timers and switches to Tuya irrigation systems (#149236) --- homeassistant/components/tuya/const.py | 9 + homeassistant/components/tuya/number.py | 60 +++ homeassistant/components/tuya/strings.json | 8 + homeassistant/components/tuya/valve.py | 140 ++++++ tests/components/tuya/__init__.py | 1 + .../tuya/fixtures/sfkzq_ed7frwissyqrejic.json | 282 +++++++++++ .../tuya/snapshots/test_number.ambr | 472 ++++++++++++++++++ .../tuya/snapshots/test_sensor.ambr | 101 ++++ .../components/tuya/snapshots/test_valve.ambr | 401 +++++++++++++++ tests/components/tuya/test_valve.py | 96 ++++ 10 files changed, 1570 insertions(+) create mode 100644 homeassistant/components/tuya/valve.py create mode 100644 tests/components/tuya/fixtures/sfkzq_ed7frwissyqrejic.json create mode 100644 tests/components/tuya/snapshots/test_valve.ambr create mode 100644 tests/components/tuya/test_valve.py diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 62a6c904a1f..752911f98f6 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -66,6 +66,7 @@ PLATFORMS = [ Platform.SIREN, Platform.SWITCH, Platform.VACUUM, + Platform.VALVE, ] @@ -166,6 +167,14 @@ class DPCode(StrEnum): CONTROL_BACK = "control_back" CONTROL_BACK_MODE = "control_back_mode" COUNTDOWN = "countdown" # Countdown + COUNTDOWN_1 = "countdown_1" + COUNTDOWN_2 = "countdown_2" + COUNTDOWN_3 = "countdown_3" + COUNTDOWN_4 = "countdown_4" + COUNTDOWN_5 = "countdown_5" + COUNTDOWN_6 = "countdown_6" + COUNTDOWN_7 = "countdown_7" + COUNTDOWN_8 = "countdown_8" COUNTDOWN_LEFT = "countdown_left" COUNTDOWN_SET = "countdown_set" # Countdown setting CRY_DETECTION_SWITCH = "cry_detection_switch" diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 88216ae3d06..7fadaa0489b 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -224,6 +224,66 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smart Water Timer + "sfkzq": ( + # Controls the irrigation duration for the water valve + NumberEntityDescription( + key=DPCode.COUNTDOWN_1, + translation_key="indexed_irrigation_duration", + translation_placeholders={"index": "1"}, + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.COUNTDOWN_2, + translation_key="indexed_irrigation_duration", + translation_placeholders={"index": "2"}, + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.COUNTDOWN_3, + translation_key="indexed_irrigation_duration", + translation_placeholders={"index": "3"}, + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.COUNTDOWN_4, + translation_key="indexed_irrigation_duration", + translation_placeholders={"index": "4"}, + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.COUNTDOWN_5, + translation_key="indexed_irrigation_duration", + translation_placeholders={"index": "5"}, + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.COUNTDOWN_6, + translation_key="indexed_irrigation_duration", + translation_placeholders={"index": "6"}, + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.COUNTDOWN_7, + translation_key="indexed_irrigation_duration", + translation_placeholders={"index": "7"}, + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.COUNTDOWN_8, + translation_key="indexed_irrigation_duration", + translation_placeholders={"index": "8"}, + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + ), # Siren Alarm # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu "sgbj": ( diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index c8268484c3a..fab93b52fc1 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -148,6 +148,9 @@ "heat_preservation_time": { "name": "Heat preservation time" }, + "indexed_irrigation_duration": { + "name": "Irrigation duration {index}" + }, "feed": { "name": "Feed" }, @@ -899,6 +902,11 @@ "frost_protection": { "name": "Frost protection" } + }, + "valve": { + "indexed_valve": { + "name": "Valve {index}" + } } }, "exceptions": { diff --git a/homeassistant/components/tuya/valve.py b/homeassistant/components/tuya/valve.py new file mode 100644 index 00000000000..06218c7030f --- /dev/null +++ b/homeassistant/components/tuya/valve.py @@ -0,0 +1,140 @@ +"""Support for Tuya valves.""" + +from __future__ import annotations + +from tuya_sharing import CustomerDevice, Manager + +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityDescription, + ValveEntityFeature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import TuyaConfigEntry +from .const import TUYA_DISCOVERY_NEW, DPCode +from .entity import TuyaEntity + +# All descriptions can be found here. Mostly the Boolean data types in the +# default instruction set of each category end up being a Valve. +# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq +VALVES: dict[str, tuple[ValveEntityDescription, ...]] = { + # Smart Water Timer + "sfkzq": ( + ValveEntityDescription( + key=DPCode.SWITCH_1, + translation_key="indexed_valve", + translation_placeholders={"index": "1"}, + device_class=ValveDeviceClass.WATER, + ), + ValveEntityDescription( + key=DPCode.SWITCH_2, + translation_key="indexed_valve", + translation_placeholders={"index": "2"}, + device_class=ValveDeviceClass.WATER, + ), + ValveEntityDescription( + key=DPCode.SWITCH_3, + translation_key="indexed_valve", + translation_placeholders={"index": "3"}, + device_class=ValveDeviceClass.WATER, + ), + ValveEntityDescription( + key=DPCode.SWITCH_4, + translation_key="indexed_valve", + translation_placeholders={"index": "4"}, + device_class=ValveDeviceClass.WATER, + ), + ValveEntityDescription( + key=DPCode.SWITCH_5, + translation_key="indexed_valve", + translation_placeholders={"index": "5"}, + device_class=ValveDeviceClass.WATER, + ), + ValveEntityDescription( + key=DPCode.SWITCH_6, + translation_key="indexed_valve", + translation_placeholders={"index": "6"}, + device_class=ValveDeviceClass.WATER, + ), + ValveEntityDescription( + key=DPCode.SWITCH_7, + translation_key="indexed_valve", + translation_placeholders={"index": "7"}, + device_class=ValveDeviceClass.WATER, + ), + ValveEntityDescription( + key=DPCode.SWITCH_8, + translation_key="indexed_valve", + translation_placeholders={"index": "8"}, + device_class=ValveDeviceClass.WATER, + ), + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up tuya valves dynamically through tuya discovery.""" + hass_data = entry.runtime_data + + @callback + def async_discover_device(device_ids: list[str]) -> None: + """Discover and add a discovered tuya valve.""" + entities: list[TuyaValveEntity] = [] + for device_id in device_ids: + device = hass_data.manager.device_map[device_id] + if descriptions := VALVES.get(device.category): + entities.extend( + TuyaValveEntity(device, hass_data.manager, description) + for description in descriptions + if description.key in device.status + ) + + async_add_entities(entities) + + async_discover_device([*hass_data.manager.device_map]) + + entry.async_on_unload( + async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) + ) + + +class TuyaValveEntity(TuyaEntity, ValveEntity): + """Tuya Valve Device.""" + + _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + + def __init__( + self, + device: CustomerDevice, + device_manager: Manager, + description: ValveEntityDescription, + ) -> None: + """Init TuyaValveEntity.""" + super().__init__(device, device_manager) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}{description.key}" + + @property + def is_closed(self) -> bool: + """Return if the valve is closed.""" + return not self.device.status.get(self.entity_description.key, False) + + async def async_open_valve(self) -> None: + """Open the valve.""" + await self.hass.async_add_executor_job( + self._send_command, [{"code": self.entity_description.key, "value": True}] + ) + + async def async_close_valve(self) -> None: + """Close the valve.""" + await self.hass.async_add_executor_job( + self._send_command, [{"code": self.entity_description.key, "value": False}] + ) diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 44fe55fbb0b..5daa3d77427 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -140,6 +140,7 @@ DEVICE_MOCKS = [ "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_ed7frwissyqrejic", # https://github.com/home-assistant/core/pull/149236 "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 diff --git a/tests/components/tuya/fixtures/sfkzq_ed7frwissyqrejic.json b/tests/components/tuya/fixtures/sfkzq_ed7frwissyqrejic.json new file mode 100644 index 00000000000..e301e25930f --- /dev/null +++ b/tests/components/tuya/fixtures/sfkzq_ed7frwissyqrejic.json @@ -0,0 +1,282 @@ +{ + "endpoint": "https://apigw.tuyacn.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "\u63a5HA\u6c34\u9600", + "category": "sfkzq", + "product_id": "ed7frwissyqrejic", + "product_name": "\u63a5HA\u6c34\u9600", + "online": true, + "sub": false, + "time_zone": "+08:00", + "active_time": "2025-07-14T06:32:48+00:00", + "create_time": "2025-07-14T06:32:48+00:00", + "update_time": "2025-07-14T06:32:48+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": {} + }, + "switch_7": { + "type": "Boolean", + "value": {} + }, + "switch_8": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_3": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_4": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_5": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_6": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_7": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_8": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + } + }, + "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": {} + }, + "switch_7": { + "type": "Boolean", + "value": {} + }, + "switch_8": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_3": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_4": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_5": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_6": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_7": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_8": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + } + }, + "status": { + "switch_1": true, + "switch_2": false, + "switch_3": true, + "switch_4": false, + "switch_5": true, + "switch_6": false, + "switch_7": true, + "switch_8": false, + "countdown_1": 1, + "countdown_2": 1, + "countdown_3": 3, + "countdown_4": 2, + "countdown_5": 2, + "countdown_6": 3, + "countdown_7": 1, + "countdown_8": 1, + "battery_percentage": 0, + "battery_state": "low" + }, + "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 adfc7543a3e..b1007431046 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -643,6 +643,478 @@ 'state': '3.0', }) # --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1439.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.jie_hashui_fa_irrigation_duration_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': 'Irrigation duration 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_irrigation_duration', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfscountdown_1', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': '接HA水阀 Irrigation duration 1', + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1439.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.jie_hashui_fa_irrigation_duration_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': 'Irrigation duration 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_irrigation_duration', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfscountdown_2', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': '接HA水阀 Irrigation duration 2', + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1439.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.jie_hashui_fa_irrigation_duration_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': 'Irrigation duration 3', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_irrigation_duration', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfscountdown_3', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': '接HA水阀 Irrigation duration 3', + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1439.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.jie_hashui_fa_irrigation_duration_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': 'Irrigation duration 4', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_irrigation_duration', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfscountdown_4', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': '接HA水阀 Irrigation duration 4', + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1439.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.jie_hashui_fa_irrigation_duration_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': 'Irrigation duration 5', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_irrigation_duration', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfscountdown_5', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': '接HA水阀 Irrigation duration 5', + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1439.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.jie_hashui_fa_irrigation_duration_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': 'Irrigation duration 6', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_irrigation_duration', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfscountdown_6', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': '接HA水阀 Irrigation duration 6', + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1439.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.jie_hashui_fa_irrigation_duration_7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irrigation duration 7', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_irrigation_duration', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfscountdown_7', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': '接HA水阀 Irrigation duration 7', + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_8-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1439.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.jie_hashui_fa_irrigation_duration_8', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irrigation duration 8', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_irrigation_duration', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfscountdown_8', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_8-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': '接HA水阀 Irrigation duration 8', + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_8', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- # name: test_platform_setup_and_discovery[number.kabinet_temperature_correction-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 375e52f6bbd..88731f34f10 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -6094,6 +6094,107 @@ 'state': '9.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.jie_hashui_fa_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.jie_hashui_fa_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.cijerqyssiwrf7deqzkfsbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.jie_hashui_fa_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': '接HA水阀 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.jie_hashui_fa_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.jie_hashui_fa_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.jie_hashui_fa_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.cijerqyssiwrf7deqzkfsbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.jie_hashui_fa_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '接HA水阀 Battery state', + }), + 'context': , + 'entity_id': 'sensor.jie_hashui_fa_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- # name: test_platform_setup_and_discovery[sensor.kalado_air_purifier_air_quality-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_valve.ambr b/tests/components/tuya/snapshots/test_valve.ambr new file mode 100644 index 00000000000..cb5f78a5610 --- /dev/null +++ b/tests/components/tuya/snapshots/test_valve.ambr @@ -0,0 +1,401 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_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': 'valve', + 'entity_category': None, + 'entity_id': 'valve.jie_hashui_fa_valve_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': 'Valve 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'indexed_valve', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfsswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': '接HA水阀 Valve 1', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.jie_hashui_fa_valve_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_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': 'valve', + 'entity_category': None, + 'entity_id': 'valve.jie_hashui_fa_valve_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': 'Valve 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'indexed_valve', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfsswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': '接HA水阀 Valve 2', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.jie_hashui_fa_valve_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_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': 'valve', + 'entity_category': None, + 'entity_id': 'valve.jie_hashui_fa_valve_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': 'Valve 3', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'indexed_valve', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfsswitch_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': '接HA水阀 Valve 3', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.jie_hashui_fa_valve_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_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': 'valve', + 'entity_category': None, + 'entity_id': 'valve.jie_hashui_fa_valve_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': 'Valve 4', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'indexed_valve', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfsswitch_4', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': '接HA水阀 Valve 4', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.jie_hashui_fa_valve_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_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': 'valve', + 'entity_category': None, + 'entity_id': 'valve.jie_hashui_fa_valve_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': 'Valve 5', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'indexed_valve', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfsswitch_5', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': '接HA水阀 Valve 5', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.jie_hashui_fa_valve_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_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': 'valve', + 'entity_category': None, + 'entity_id': 'valve.jie_hashui_fa_valve_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': 'Valve 6', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'indexed_valve', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfsswitch_6', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': '接HA水阀 Valve 6', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.jie_hashui_fa_valve_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'valve', + 'entity_category': None, + 'entity_id': 'valve.jie_hashui_fa_valve_7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve 7', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'indexed_valve', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfsswitch_7', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': '接HA水阀 Valve 7', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.jie_hashui_fa_valve_7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_8-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'valve', + 'entity_category': None, + 'entity_id': 'valve.jie_hashui_fa_valve_8', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve 8', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'indexed_valve', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfsswitch_8', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_8-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': '接HA水阀 Valve 8', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.jie_hashui_fa_valve_8', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/tuya/test_valve.py b/tests/components/tuya/test_valve.py new file mode 100644 index 00000000000..9c00f0f4c75 --- /dev/null +++ b/tests/components/tuya/test_valve.py @@ -0,0 +1,96 @@ +"""Test Tuya valve 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.valve import ( + DOMAIN as VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.VALVE]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + 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_devices) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + ["sfkzq_ed7frwissyqrejic"], +) +async def test_open_valve( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test opening a valve.""" + entity_id = "valve.jie_hashui_fa_valve_1" + 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( + VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + { + "entity_id": entity_id, + }, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "switch_1", "value": True}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["sfkzq_ed7frwissyqrejic"], +) +async def test_close_valve( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test closing a valve.""" + entity_id = "valve.jie_hashui_fa_valve_1" + 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( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + { + "entity_id": entity_id, + }, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "switch_1", "value": False}] + ) From fb4a4528723e38797f9f905b8c8e2444a6f5fc48 Mon Sep 17 00:00:00 2001 From: yufeng Date: Tue, 12 Aug 2025 17:02:03 +0800 Subject: [PATCH 0937/1113] Add supply frequency sensors to Tuya energy monitoring devices (#149320) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/tuya/const.py | 1 + homeassistant/components/tuya/sensor.py | 14 + homeassistant/components/tuya/strings.json | 3 + tests/components/tuya/__init__.py | 1 + .../tuya/fixtures/dlq_cnpkf4xdmd9v49iq.json | 158 ++++++++ .../tuya/snapshots/test_sensor.ambr | 336 ++++++++++++++++++ .../tuya/snapshots/test_switch.ambr | 96 +++++ 7 files changed, 609 insertions(+) create mode 100644 tests/components/tuya/fixtures/dlq_cnpkf4xdmd9v49iq.json diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 752911f98f6..6c4b1c02b22 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -328,6 +328,7 @@ class DPCode(StrEnum): STATUS = "status" STERILIZATION = "sterilization" # Sterilization SUCTION = "suction" + SUPPLY_FREQUENCY = "supply_frequency" SWING = "swing" # Swing mode SWITCH = "switch" # Switch SWITCH_1 = "switch_1" # Switch 1 diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index a22eba6cc35..82ddb1ee0ab 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -363,6 +363,13 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + TuyaSensorEntityDescription( + key=DPCode.SUPPLY_FREQUENCY, + translation_key="supply_frequency", + device_class=SensorDeviceClass.FREQUENCY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), TuyaSensorEntityDescription( key=DPCode.PHASE_A, translation_key="phase_a_current", @@ -1346,6 +1353,13 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { complex_type=ElectricityTypeData, subkey="power", ), + TuyaSensorEntityDescription( + key=DPCode.SUPPLY_FREQUENCY, + translation_key="supply_frequency", + device_class=SensorDeviceClass.FREQUENCY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), TuyaSensorEntityDescription( key=DPCode.PHASE_A, translation_key="phase_a_current", diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index fab93b52fc1..f185e8d5489 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -743,6 +743,9 @@ }, "liquid_level": { "name": "Liquid level" + }, + "supply_frequency": { + "name": "Supply frequency" } }, "switch": { diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 5daa3d77427..a9087b92211 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -96,6 +96,7 @@ DEVICE_MOCKS = [ "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_cnpkf4xdmd9v49iq", # https://github.com/home-assistant/core/pull/149320 "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 diff --git a/tests/components/tuya/fixtures/dlq_cnpkf4xdmd9v49iq.json b/tests/components/tuya/fixtures/dlq_cnpkf4xdmd9v49iq.json new file mode 100644 index 00000000000..aa42fc0f568 --- /dev/null +++ b/tests/components/tuya/fixtures/dlq_cnpkf4xdmd9v49iq.json @@ -0,0 +1,158 @@ +{ + "endpoint": "https://apigw.tuyacn.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "\u65ad\u8def\u5668HA", + "category": "dlq", + "product_id": "cnpkf4xdmd9v49iq", + "product_name": "Breaker", + "online": true, + "sub": false, + "time_zone": "+08:00", + "active_time": "2025-07-03T10:19:11+00:00", + "create_time": "2025-07-03T10:19:11+00:00", + "update_time": "2025-07-03T10:19:11+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "alarm_set_1": { + "type": "Raw", + "value": {} + }, + "alarm_set_2": { + "type": "Raw", + "value": {} + }, + "leakagecurr_test": { + "type": "Boolean", + "value": {} + }, + "online_state": { + "type": "Enum", + "value": { + "range": ["online", "offline"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "forward_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 3, + "step": 1 + } + }, + "phase_a": { + "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", + "leakage_early_warning", + "overcur_early_warning", + "overvol_early_warning", + "overpow_early_warning", + "undvol_early_warning", + "higtemp_early_warning" + ] + } + }, + "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": {} + }, + "breaker_number": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "leakagecurr_test": { + "type": "Boolean", + "value": {} + }, + "supply_frequency": { + "type": "Integer", + "value": { + "unit": "Hz", + "min": 0, + "max": 9999, + "scale": 2, + "step": 1 + } + }, + "online_state": { + "type": "Enum", + "value": { + "range": ["online", "offline"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "forward_energy_total": 120, + "phase_a": "Ag8JJQAASAAACAAAAAAACGME", + "fault": 0, + "leakage_current": 0, + "switch": false, + "alarm_set_1": "", + "alarm_set_2": "", + "breaker_number": "", + "leakagecurr_test": false, + "supply_frequency": 0, + "online_state": "online", + "child_lock": false + }, + "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 88731f34f10..53003ac095a 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -2818,6 +2818,230 @@ 'state': '222.4', }) # --- +# name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_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.duan_lu_qi_ha_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.qi94v9dmdx4fkpncqldphase_aelectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_phase_a_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': '断路器HA Phase A current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.duan_lu_qi_ha_phase_a_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '599.296', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_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.duan_lu_qi_ha_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.qi94v9dmdx4fkpncqldphase_apower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_phase_a_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': '断路器HA Phase A power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.duan_lu_qi_ha_phase_a_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.432', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_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.duan_lu_qi_ha_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.qi94v9dmdx4fkpncqldphase_avoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_phase_a_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': '断路器HA Phase A voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.duan_lu_qi_ha_phase_a_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '52.7', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_supply_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': , + 'entity_id': 'sensor.duan_lu_qi_ha_supply_frequency', + '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': 'Supply frequency', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'supply_frequency', + 'unique_id': 'tuya.qi94v9dmdx4fkpncqldsupply_frequency', + 'unit_of_measurement': 'Hz', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_supply_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': '断路器HA Supply frequency', + 'state_class': , + 'unit_of_measurement': 'Hz', + }), + 'context': , + 'entity_id': 'sensor.duan_lu_qi_ha_supply_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.eau_chaude_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7641,6 +7865,62 @@ 'state': '220.4', }) # --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_supply_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': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_supply_frequency', + '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': 'Supply frequency', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'supply_frequency', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldsupply_frequency', + 'unit_of_measurement': 'Hz', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_supply_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Metering_3PN_WiFi_stable Supply frequency', + 'state_class': , + 'unit_of_measurement': 'Hz', + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_supply_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.motion_sensor_lidl_zigbee_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -12542,6 +12822,62 @@ 'state': '52.7', }) # --- +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_supply_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': , + 'entity_id': 'sensor.xoca_dac212xc_v2_s1_supply_frequency', + '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': 'Supply frequency', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'supply_frequency', + 'unique_id': 'tuya.9oh1h1uyalfykgg4bdnzsupply_frequency', + 'unit_of_measurement': 'Hz', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_supply_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'XOCA-DAC212XC V2-S1 Supply frequency', + 'state_class': , + 'unit_of_measurement': 'Hz', + }), + 'context': , + 'entity_id': 'sensor.xoca_dac212xc_v2_s1_supply_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_total_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index bff8c3e392a..d33c91118ef 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -2565,6 +2565,102 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.duan_lu_qi_ha_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.duan_lu_qi_ha_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.qi94v9dmdx4fkpncqldchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.duan_lu_qi_ha_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '断路器HA Child lock', + }), + 'context': , + 'entity_id': 'switch.duan_lu_qi_ha_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.duan_lu_qi_ha_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.duan_lu_qi_ha_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.qi94v9dmdx4fkpncqldswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.duan_lu_qi_ha_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '断路器HA Switch', + }), + 'context': , + 'entity_id': 'switch.duan_lu_qi_ha_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.eau_chaude_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 596e4883b1b2207c82b59dcdda0d9e651fb3e14f Mon Sep 17 00:00:00 2001 From: Nippey Date: Tue, 12 Aug 2025 11:33:51 +0200 Subject: [PATCH 0938/1113] Add more sensors to Tuya weather station (#150442) Co-authored-by: Franck Nijhof Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/tuya/const.py | 10 + homeassistant/components/tuya/sensor.py | 50 ++++ homeassistant/components/tuya/strings.json | 12 + .../tuya/snapshots/test_sensor.ambr | 215 ++++++++++++++++++ 4 files changed, 287 insertions(+) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 6c4b1c02b22..b60b3bc518c 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -24,6 +24,7 @@ from homeassistant.const import ( UnitOfPressure, UnitOfTemperature, UnitOfVolume, + UnitOfVolumetricFlux, ) DOMAIN = "tuya" @@ -296,6 +297,8 @@ class DPCode(StrEnum): PUMP_RESET = "pump_reset" # Water pump reset PUMP_TIME = "pump_time" # Water pump duration OXYGEN = "oxygen" # Oxygen bar + RAIN_24H = "rain_24h" # Total daily rainfall in mm + RAIN_RATE = "rain_rate" # Rain intensity in mm/h RECORD_MODE = "record_mode" RECORD_SWITCH = "record_switch" # Recording switch RELAY_STATUS = "relay_status" @@ -415,6 +418,7 @@ class DPCode(StrEnum): UPPER_TEMP = "upper_temp" UPPER_TEMP_F = "upper_temp_f" UV = "uv" # UV sterilization + UV_INDEX = "uv_index" UV_RUNTIME = "uv_runtime" # UV runtime VA_BATTERY = "va_battery" VA_HUMIDITY = "va_humidity" @@ -439,6 +443,7 @@ class DPCode(StrEnum): WINDOW_STATE = "window_state" WINDSPEED = "windspeed" WINDSPEED_AVG = "windspeed_avg" + WIND_DIRECT = "wind_direct" WIRELESS_BATTERYLOCK = "wireless_batterylock" WIRELESS_ELECTRICITY = "wireless_electricity" WORK_MODE = "work_mode" # Working mode @@ -524,6 +529,11 @@ UNITS = ( aliases={"m3"}, device_classes={SensorDeviceClass.GAS}, ), + UnitOfMeasurement( + unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + aliases={"mm"}, + device_classes={SensorDeviceClass.PRECIPITATION_INTENSITY}, + ), UnitOfMeasurement( unit=LIGHT_LUX, aliases={"lux"}, diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 82ddb1ee0ab..275b7c28e3a 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -2,7 +2,9 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass +from typing import Any from tuya_sharing import CustomerDevice, Manager from tuya_sharing.device import DeviceStatusRange @@ -42,6 +44,25 @@ from .const import ( from .entity import TuyaEntity from .models import ComplexTypeData, ElectricityTypeData, EnumTypeData, IntegerTypeData +_WIND_DIRECTIONS = { + "north": 0.0, + "north_north_east": 22.5, + "north_east": 45.0, + "east_north_east": 67.5, + "east": 90.0, + "east_south_east": 112.5, + "south_east": 135.0, + "south_south_east": 157.5, + "south": 180.0, + "south_south_west": 202.5, + "south_west": 225.0, + "west_south_west": 247.5, + "west": 270.0, + "west_north_west": 292.5, + "north_west": 315.0, + "north_north_west": 337.5, +} + @dataclass(frozen=True) class TuyaSensorEntityDescription(SensorEntityDescription): @@ -49,6 +70,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): complex_type: type[ComplexTypeData] | None = None subkey: str | None = None + state_conversion: Callable[[Any], StateType] | None = None # Commonly used battery sensors, that are reused in the sensors down below. @@ -931,6 +953,30 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.RAIN_24H, + translation_key="precipitation_today", + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.RAIN_RATE, + translation_key="precipitation_intensity", + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.UV_INDEX, + translation_key="uv_index", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.WIND_DIRECT, + translation_key="wind_direction", + device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT, + state_conversion=lambda state: _WIND_DIRECTIONS.get(str(state)), + ), *BATTERY_SENSORS, ), # Gas Detector @@ -1625,6 +1671,10 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): if value is None: return None + # Convert value, if required + if (convert := self.entity_description.state_conversion) is not None: + return convert(value) + # Scale integer/float value if isinstance(self._type_data, IntegerTypeData): return self._type_data.scale_value(value) diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index f185e8d5489..fa15e34694c 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -535,6 +535,18 @@ "air_pressure": { "name": "Air pressure" }, + "precipitation_today": { + "name": "Total precipitation today" + }, + "precipitation_intensity": { + "name": "[%key:component::sensor::entity_component::precipitation_intensity::name%]" + }, + "uv_index": { + "name": "UV index" + }, + "wind_direction": { + "name": "[%key:component::sensor::entity_component::wind_direction::name%]" + }, "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 53003ac095a..0d113454d4d 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -1867,6 +1867,62 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_precipitation_intensity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_precipitation_intensity', + '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': 'Precipitation intensity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precipitation_intensity', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqrain_rate', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_precipitation_intensity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'precipitation_intensity', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Precipitation intensity', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_precipitation_intensity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2147,6 +2203,165 @@ 'state': '24.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_total_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.br_7_in_1_wlan_wetterstation_anthrazit_total_precipitation_today', + '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': 'Total precipitation today', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precipitation_today', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqrain_24h', + 'unit_of_measurement': 'mm', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_total_precipitation_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'precipitation', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Total precipitation today', + 'state_class': , + 'unit_of_measurement': 'mm', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_total_precipitation_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_uv_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.br_7_in_1_wlan_wetterstation_anthrazit_uv_index', + 'has_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 index', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'uv_index', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxquv_index', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_uv_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit UV index', + 'state_class': , + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_uv_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wind direction', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wind_direction', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqwind_direct', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Wind direction', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 067cab71fabbae7a9e1bf6e9639244bc733e74fe Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Tue, 12 Aug 2025 10:55:21 +0100 Subject: [PATCH 0939/1113] Additional Fix error on startup when no Apps or Radio plugins are installed for Squeezebox (#150475) --- homeassistant/components/squeezebox/browse_media.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index e14f1989cbe..cebd4fcb04f 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -157,7 +157,7 @@ class BrowseData: cmd = ["apps", 0, browse_limit] result = await player.async_query(*cmd) - if result["appss_loop"]: + if result and result.get("appss_loop"): for app in result["appss_loop"]: app_cmd = "app-" + app["cmd"] if app_cmd not in self.known_apps_radios: @@ -169,7 +169,7 @@ class BrowseData: ) cmd = ["radios", 0, browse_limit] result = await player.async_query(*cmd) - if result["radioss_loop"]: + if result and result.get("radioss_loop"): for app in result["radioss_loop"]: app_cmd = "app-" + app["cmd"] if app_cmd not in self.known_apps_radios: From 8edbcc92d35691c308a30f522f244259c0737207 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Tue, 12 Aug 2025 11:55:43 +0200 Subject: [PATCH 0940/1113] Fix enphase_envoy non existing via device warning at first config. (#149010) Co-authored-by: Joost Lekkerkerker --- .../components/enphase_envoy/__init__.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index e95ab1179e1..62d276b4224 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from pyenphase import Envoy from homeassistant.const import CONF_HOST @@ -42,6 +44,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> b }, ) + # register envoy before via_device is used + device_registry = dr.async_get(hass) + if TYPE_CHECKING: + assert envoy.serial_number + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, envoy.serial_number)}, + manufacturer="Enphase", + name=coordinator.name, + model=envoy.envoy_model, + sw_version=str(envoy.firmware), + hw_version=envoy.part_number, + serial_number=envoy.serial_number, + ) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) From 313b5a483cf904fb273744e01490800fc2c15bfa Mon Sep 17 00:00:00 2001 From: "Etienne C." <59794011+etiennec78@users.noreply.github.com> Date: Tue, 12 Aug 2025 12:20:48 +0200 Subject: [PATCH 0941/1113] Remove rounding of Waze duration sensor (#150424) --- homeassistant/components/waze_travel_time/sensor.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index e56edfae53d..c1323ce9397 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -39,6 +39,7 @@ class WazeTravelTimeSensor(CoordinatorEntity[WazeTravelTimeCoordinator], SensorE _attr_attribution = "Powered by Waze" _attr_native_unit_of_measurement = UnitOfTime.MINUTES + _attr_suggested_display_precision = 0 _attr_device_class = SensorDeviceClass.DURATION _attr_state_class = SensorStateClass.MEASUREMENT _attr_device_info = DeviceInfo( @@ -63,11 +64,8 @@ class WazeTravelTimeSensor(CoordinatorEntity[WazeTravelTimeCoordinator], SensorE @property def native_value(self) -> float | None: """Return the state of the sensor.""" - if ( - self.coordinator.data is not None - and self.coordinator.data.duration is not None - ): - return round(self.coordinator.data.duration) + if self.coordinator.data is not None: + return self.coordinator.data.duration return None @property From 08aae4bf4956af960dc82cbc162b49c33b9a728a Mon Sep 17 00:00:00 2001 From: David Date: Tue, 12 Aug 2025 12:45:21 +0200 Subject: [PATCH 0942/1113] Fix error of the Powerfox integration in combination with the new Powerfox FLOW adapter (#150429) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/powerfox/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/powerfox/__init__.py b/homeassistant/components/powerfox/__init__.py index 8e51985211d..c2f6830692c 100644 --- a/homeassistant/components/powerfox/__init__.py +++ b/homeassistant/components/powerfox/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio -from powerfox import Powerfox, PowerfoxConnectionError +from powerfox import DeviceType, Powerfox, PowerfoxConnectionError from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant @@ -31,7 +31,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: PowerfoxConfigEntry) -> raise ConfigEntryNotReady from err coordinators: list[PowerfoxDataUpdateCoordinator] = [ - PowerfoxDataUpdateCoordinator(hass, entry, client, device) for device in devices + PowerfoxDataUpdateCoordinator(hass, entry, client, device) + for device in devices + # Filter out gas meter devices (Powerfox FLOW adapters) as they are not yet supported and cause integration failures + if device.type != DeviceType.GAS_METER ] await asyncio.gather( From 66ff1cf00538b9891bfc77259546c96c3f60b7bb Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 12 Aug 2025 13:47:11 +0200 Subject: [PATCH 0943/1113] Improve Z-Wave manual config flow step description (#150479) --- .../components/zwave_js/config_flow.py | 28 +++++++++++++++++-- .../components/zwave_js/strings.json | 8 ++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 6121bd00508..b72a71279ab 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -88,11 +88,16 @@ ADDON_USER_INPUT_MAP = { CONF_ADDON_LR_S2_AUTHENTICATED_KEY: CONF_LR_S2_AUTHENTICATED_KEY, } +EXAMPLE_SERVER_URL = "ws://localhost:3000" ON_SUPERVISOR_SCHEMA = vol.Schema({vol.Optional(CONF_USE_ADDON, default=True): bool}) MIN_MIGRATION_SDK_VERSION = AwesomeVersion("6.61") NETWORK_TYPE_NEW = "new" NETWORK_TYPE_EXISTING = "existing" +ZWAVE_JS_SERVER_INSTRUCTIONS = ( + "https://www.home-assistant.io/integrations/zwave_js/" + "#advanced-installation-instructions" +) 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" @@ -529,7 +534,12 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a manual configuration.""" if user_input is None: return self.async_show_form( - step_id="manual", data_schema=get_manual_schema({}) + step_id="manual", + data_schema=get_manual_schema({}), + description_placeholders={ + "example_server_url": EXAMPLE_SERVER_URL, + "server_instructions": ZWAVE_JS_SERVER_INSTRUCTIONS, + }, ) errors = {} @@ -558,7 +568,13 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): return self._async_create_entry_from_vars() return self.async_show_form( - step_id="manual", data_schema=get_manual_schema(user_input), errors=errors + step_id="manual", + data_schema=get_manual_schema(user_input), + description_placeholders={ + "example_server_url": EXAMPLE_SERVER_URL, + "server_instructions": ZWAVE_JS_SERVER_INSTRUCTIONS, + }, + errors=errors, ) async def async_step_hassio( @@ -1016,6 +1032,10 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="manual_reconfigure", data_schema=get_manual_schema({CONF_URL: config_entry.data[CONF_URL]}), + description_placeholders={ + "example_server_url": EXAMPLE_SERVER_URL, + "server_instructions": ZWAVE_JS_SERVER_INSTRUCTIONS, + }, ) errors = {} @@ -1046,6 +1066,10 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="manual_reconfigure", data_schema=get_manual_schema(user_input), + description_placeholders={ + "example_server_url": EXAMPLE_SERVER_URL, + "server_instructions": ZWAVE_JS_SERVER_INSTRUCTIONS, + }, errors=errors, ) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 8ac356a40b0..0ff635578ea 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -82,13 +82,21 @@ "title": "Installing add-on" }, "manual": { + "description": "The Z-Wave integration requires a running Z-Wave Server. If you don't already have that set up, please read the [instructions]({server_instructions}) in our documentation.\n\nWhen you have a Z-Wave Server running, enter its URL below to allow the integration to connect.", "data": { "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "url": "The URL of the Z-Wave Server WebSocket API, e.g. {example_server_url}" } }, "manual_reconfigure": { + "description": "[%key:component::zwave_js::config::step::manual::description%]", "data": { "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "url": "[%key:component::zwave_js::config::step::manual::data_description::url%]" } }, "on_supervisor": { From 7ebdd242249e1592afa698a2adc15e669a18d2ff Mon Sep 17 00:00:00 2001 From: Matrix Date: Tue, 12 Aug 2025 19:55:04 +0800 Subject: [PATCH 0944/1113] Bump yolink api to 0.5.8 (#150480) --- homeassistant/components/yolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 89001f98c16..138667e7e73 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.7"] + "requirements": ["yolink-api==0.5.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4cd27301bec..35fa727fb86 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3173,7 +3173,7 @@ yeelight==0.7.16 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.5.7 +yolink-api==0.5.8 # homeassistant.components.youless youless-api==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 537ca44d85e..f8e369d098d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2620,7 +2620,7 @@ yalexs==8.11.1 yeelight==0.7.16 # homeassistant.components.yolink -yolink-api==0.5.7 +yolink-api==0.5.8 # homeassistant.components.youless youless-api==2.2.0 From 2612dbeb9bcbd30ae099d1a2435477271ad0b599 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Tue, 12 Aug 2025 13:58:38 +0200 Subject: [PATCH 0945/1113] Add missing boost2 code for Miele hobs (#150481) --- homeassistant/components/miele/const.py | 1 + homeassistant/components/miele/icons.json | 3 +- homeassistant/components/miele/strings.json | 3 +- .../miele/snapshots/test_sensor.ambr | 28 +++++++++++++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index e8b626af785..3b5b13398a5 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -1330,4 +1330,5 @@ class PlatePowerStep(MieleEnum): plate_step_17 = 17 plate_step_18 = 18 plate_step_boost = 117, 118, 218 + plate_step_boost_2 = 217 missing2none = -9999 diff --git a/homeassistant/components/miele/icons.json b/homeassistant/components/miele/icons.json index 77d94c49ffa..a5dbeb4ec2d 100644 --- a/homeassistant/components/miele/icons.json +++ b/homeassistant/components/miele/icons.json @@ -76,7 +76,8 @@ "plate_step_16": "mdi:circle-slice-7", "plate_step_17": "mdi:circle-slice-8", "plate_step_18": "mdi:circle-slice-8", - "plate_step_boost": "mdi:alpha-b-circle-outline" + "plate_step_boost": "mdi:alpha-b-circle-outline", + "plate_step_boost_2": "mdi:alpha-b-circle" } }, "program_type": { diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 90689a3d9cc..cb9861e0246 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -223,7 +223,8 @@ "plate_step_16": "8\u2022", "plate_step_17": "9", "plate_step_18": "9\u2022", - "plate_step_boost": "Boost" + "plate_step_boost": "Boost", + "plate_step_boost_2": "Boost 2" } }, "drying_step": { diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index 2805a683077..5d941550f41 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -208,6 +208,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -266,6 +267,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -304,6 +306,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -362,6 +365,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -400,6 +404,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -458,6 +463,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -496,6 +502,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -554,6 +561,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -592,6 +600,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -650,6 +659,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -688,6 +698,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -746,6 +757,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -784,6 +796,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -842,6 +855,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -880,6 +894,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -938,6 +953,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -976,6 +992,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -1034,6 +1051,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -1457,6 +1475,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -1515,6 +1534,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -1553,6 +1573,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -1611,6 +1632,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -1649,6 +1671,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -1707,6 +1730,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -1745,6 +1769,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -1803,6 +1828,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -1841,6 +1867,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -1899,6 +1926,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), From 2b70639b115a398acf786130373521218c44da54 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 12 Aug 2025 14:04:36 +0200 Subject: [PATCH 0946/1113] Add device registry snapshots to Tuya (#150482) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/tuya/snapshots/test_init.ambr | 5764 ++++++++++++++++- tests/components/tuya/test_init.py | 37 +- 2 files changed, 5756 insertions(+), 45 deletions(-) diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 1075b68ec5e..a6d5b121f49 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -1,34 +1,5736 @@ # serializer version: 1 -# name: test_unsupported_device[ydkt_jevroj5aguwdbs2e] - 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', - 'e2sbdwuga5jorvejtkdy', - ), - }), - '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, - 'sw_version': None, - 'via_device_id': None, +# name: test_device_registry[2k8wyjo7iidkohuczc] + 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', + '2k8wyjo7iidkohuczc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Plug-EU', + 'model_id': 'cuhokdii7ojyw8k2', + 'name': 'Buitenverlichting', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[2myxayqtud9aqbizsc] + 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', + '2myxayqtud9aqbizsc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Arete® Two 12L Dehumidifier/Air Purifier', + 'model_id': 'zibqa9dutqyaxym2', + 'name': 'Dehumidifier', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[2pxfek1jjrtctiyglam] + 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', + '2pxfek1jjrtctiyglam', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Multifunction alarm', + 'model_id': 'gyitctrjj1kefxp2', + 'name': 'Multifunction alarm', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[2w46jyhngklc] + 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', + '2w46jyhngklc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Curtain switch', + 'model_id': 'nhyj64w2', + 'name': 'Tapparelle studio', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[2x473nefusdo7af6zc] + 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', + '2x473nefusdo7af6zc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '5GHz plug', + 'model_id': '6fa7odsufen374x2', + 'name': 'Office', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[3d4yosotwk27nqxvzc] + 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', + '3d4yosotwk27nqxvzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Plug+', + 'model_id': 'vxqn72kwtosoy4d3', + 'name': 'Garage Socket', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[3phkffywh5nnlj5vbdnz] + 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', + '3phkffywh5nnlj5vbdnz', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Meter', + 'model_id': 'v5jlnn5hwyffkhp3', + 'name': 'Production', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[3uqk1csjqplf3uxqscm] + 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', + '3uqk1csjqplf3uxqscm', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Contact Sensor', + 'model_id': 'qxu3flpqjsc1kqu3', + 'name': 'Garage Contact Sensor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[49m7h9lh3t8pq6ftzc] + 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', + '49m7h9lh3t8pq6ftzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'smart meter with CT-2', + 'model_id': 'tf6qp8t3hl9h7m94', + 'name': 'Consommation', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[4fO1qIzYbcdMUHqAjd] + 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', + '4fO1qIzYbcdMUHqAjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Bulb', + 'model_id': 'AqHUMdcbYzIq1Of4', + 'name': 'Landing', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[4pa1uobdjd] + 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', + '4pa1uobdjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'atmosphere', + 'model_id': 'dbou1ap4', + 'name': 'Lumy Garage', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[4q5c2am8n1bwb6bszc] + 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', + '4q5c2am8n1bwb6bszc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'WIFI 插座', + 'model_id': 'sb6bwb1n8ma2c5q4', + 'name': 'Socket4', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[51tdkcsamisw9ukycp] + 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', + '51tdkcsamisw9ukycp', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Konyks Priska USB', + 'model_id': 'yku9wsimasckdt15', + 'name': 'Framboisier', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[53fnjncm3jywuaznps] + 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', + '53fnjncm3jywuaznps', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Camera ', + 'model_id': 'nzauwyj3mcnjnf35', + 'name': 'Garage Camera', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[5gfyvvg48bsxbbnjzc] + 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', + '5gfyvvg48bsxbbnjzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Plug Base 6210HA', + 'model_id': 'jnbbxsb84gvvyfg5', + 'name': 'Bathroom Fan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[63cninaczt9dwo7v2gw] + 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', + '63cninaczt9dwo7v2gw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Gateway (unsupported)', + 'model_id': 'v7owd9tzcaninc36', + 'name': 'Gateway2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[69dth3rxgcdsw] + 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', + '69dth3rxgcdsw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Temperature Humidity Sensor', + 'model_id': 'xr3htd96', + 'name': 'Humy toilettes RDC', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[6ffyxwrjsuydxhqrqkynw] + 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', + '6ffyxwrjsuydxhqrqkynw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart IR (unsupported)', + 'model_id': 'rqhxdyusjrwxyff6', + 'name': 'Smart IR', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[6gsqieoh1yzjvxlnjd] + 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', + '6gsqieoh1yzjvxlnjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LED SMART', + 'model_id': 'nlxvjzy1hoeiqsg6', + 'name': 'hall 💡 ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[6o148laaosbf0g4djd] + 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', + '6o148laaosbf0g4djd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'A60 GOLD', + 'model_id': 'd4g0fbsoaal841o6', + 'name': 'WC D1', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[6tbtkuv3tal1aesfjxq] + 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', + '6tbtkuv3tal1aesfjxq', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'BR 7-in-1 WLAN Wetterstation Anthrazit', + 'model_id': 'fsea1lat3vuktbt6', + 'name': 'BR 7-in-1 WLAN Wetterstation Anthrazit', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[73ov8i8iedtylkzrqzkfs] + 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', + '73ov8i8iedtylkzrqzkfs', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Water Timer', + 'model_id': 'rzklytdei8i8vo37', + 'name': 'balkonbewässerung', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[7axah58vfydd8cphjd] + 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', + '7axah58vfydd8cphjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'RGB Smart Plug', + 'model_id': 'hpc8ddyfv85haxa7', + 'name': 'Garage', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[7jxnjpiltmj2zyaijd] + 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', + '7jxnjpiltmj2zyaijd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LED Strip RGB+W', + 'model_id': 'iayz2jmtlipjnxj7', + 'name': 'LED Porch 2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[7obpyhy8scm] + 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', + '7obpyhy8scm', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Door Sensor', + 'model_id': '8yhypbo7', + 'name': 'Boîte aux lettres - arrière', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[7zogt3pcwhxhu8upqdt] + 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', + '7zogt3pcwhxhu8upqdt', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Plug +', + 'model_id': 'pu8uhxhwcp3tgoz7', + 'name': 'Socket3', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[86kdcut3hiqqddlijd] + 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', + '86kdcut3hiqqddlijd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LED SMART', + 'model_id': 'ilddqqih3tucdk68', + 'name': 'Ieskas', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[87yarxyp23ap1vazjd] + 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', + '87yarxyp23ap1vazjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Ceiling Light RGBTW', + 'model_id': 'zav1pa32pyxray78', + 'name': 'Gengske 💡 ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[97k3pwirjd] + 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', + '97k3pwirjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'atmosphere', + 'model_id': 'riwp3k79', + 'name': 'LED KEUKEN 2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[9oh1h1uyalfykgg4bdnz] + 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', + '9oh1h1uyalfykgg4bdnz', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'XOCA-DAC212XC V2-S1', + 'model_id': '4ggkyflayu1h1ho9', + 'name': 'XOCA-DAC212XC V2-S1', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[9wlo8cpzprhiclrkgcdsw] + 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', + '9wlo8cpzprhiclrkgcdsw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'IFS-STD002', + 'model_id': 'krlcihrpzpc8olw9', + 'name': 'IFS-STD002', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[CyD4ctKVrAFSSXSbjd] + 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', + 'CyD4ctKVrAFSSXSbjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Dimmer switch', + 'model_id': 'bSXSSFArVKtc4DyC', + 'name': 'bedroom', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[HzsAAAKFLPABVi8nzc] + 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', + 'HzsAAAKFLPABVi8nzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Socket', + 'model_id': 'n8iVBAPLFKAAAszH', + 'name': 'Steckdose 2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[LJ9zTFQTfMgsG2Ahzc] + 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', + 'LJ9zTFQTfMgsG2Ahzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Mini Smart Socket', + 'model_id': 'hA2GsgMfTQFTz9JL', + 'name': 'Spot 4', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[LS6FfVBVU1vzBRBHzc] + 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', + 'LS6FfVBVU1vzBRBHzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Rewireable Plug 6930HA', + 'model_id': 'HBRBzv1UVBVfF6SL', + 'name': 'Rewireable Plug 6930HA', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[O8QpxJwdme33sqn4gk] + 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', + 'O8QpxJwdme33sqn4gk', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'SWITCH1', + 'model_id': '4nqs33emdwJxpQ8O', + 'name': 'office lights', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ZgXzZULP6dDp4Atvgcdsw] + 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', + 'ZgXzZULP6dDp4Atvgcdsw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Temperature and humidity sensor', + 'model_id': 'vtA4pDd6PLUZzXgZ', + 'name': 'Humy bain', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[a3qtb7pulkcc6jdjqld] + 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', + 'a3qtb7pulkcc6jdjqld', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'WiFi Din Rail Switch with metering', + 'model_id': 'jdj6ccklup7btq3a', + 'name': 'Eau Chaude', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[a4zeazrz1ata9mbggk] + 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', + 'a4zeazrz1ata9mbggk', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Valve', + 'model_id': 'gbm9ata1zrzaez4a', + 'name': 'QT-Switch', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[aa99hccfnzvypr3zjsywc] + 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', + 'aa99hccfnzvypr3zjsywc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '', + 'model_id': 'z3rpyvznfcch99aa', + 'name': 'PIXI Smart Drinking Fountain', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ajkdo1bm2rcmpuufjd] + 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', + 'ajkdo1bm2rcmpuufjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'ST64 Clear', + 'model_id': 'fuupmcr2mb1odkja', + 'name': 'Slaapkamer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[aoyweq8xbx7qfndijd] + 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', + 'aoyweq8xbx7qfndijd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Lamp', + 'model_id': 'idnfq7xbx8qewyoa', + 'name': 'AB1', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ase6htln9tdni2sijxq] + 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', + 'ase6htln9tdni2sijxq', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'T & H Sensor with external probe', + 'model_id': 'is2indt9nlth6esa', + 'name': 'Frysen', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[b6e05dfy4qhpgea1qdt] + 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', + 'b6e05dfy4qhpgea1qdt', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '1-433', + 'model_id': '1aegphq4yfd50e6b', + 'name': 'jardin Fraises', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[bFFsO8HimyAJGIj7scm] + 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', + 'bFFsO8HimyAJGIj7scm', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Door Sensor', + 'model_id': '7jIGJAymiH8OsFFb', + 'name': 'Door Garage ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[bak2crzmabancwqvjd] + 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', + 'bak2crzmabancwqvjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Light Strip-RGBCW ', + 'model_id': 'vqwcnabamzrc2kab', + 'name': 'Strip 2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[bcyciyhhu1g2gk9rqld] + 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', + 'bcyciyhhu1g2gk9rqld', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Breaker ', + 'model_id': 'r9kg2g1uhhyicycb', + 'name': 'P1 Energia Elettrica', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[bescacsciyam3aouqdt] + 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', + 'bescacsciyam3aouqdt', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Curtain switch (unsupported)', + 'model_id': 'uoa3mayicscacseb', + 'name': 'Living room left', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[bgnj6bafrdgb1xmajd] + 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', + 'bgnj6bafrdgb1xmajd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'A60 Clear', + 'model_id': 'amx1bgdrfab6jngb', + 'name': 'Lumy Hall', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[buzituffc13pgb1jjd] + 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', + 'buzituffc13pgb1jjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LSC Smart Ceiling Light', + 'model_id': 'j1bgp31cffutizub', + 'name': 'Ceiling Portal', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[bxfkpxjgux2fgwnazc] + 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', + 'bxfkpxjgux2fgwnazc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Socket ', + 'model_id': 'anwgf2xugjxpkfxb', + 'name': 'Security Light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[c1tfgunpf6optybisf] + 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', + 'c1tfgunpf6optybisf', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Tower bladeless fan ', + 'model_id': 'ibytpo6fpnugft1c', + 'name': 'Ventilador Cama', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[cijerqyssiwrf7deqzkfs] + 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', + 'cijerqyssiwrf7deqzkfs', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '接HA水阀', + 'model_id': 'ed7frwissyqrejic', + 'name': '接HA水阀', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[cju47ovcbeuapei2zc] + 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', + 'cju47ovcbeuapei2zc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Aubess Smart\xa0Socket 20A/EM', + 'model_id': '2iepauebcvo74ujc', + 'name': 'Aubess Cooker', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[codvtvgtjs] + 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', + 'codvtvgtjs', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Rain sensor', + 'model_id': 'tgvtvdoc', + 'name': 'Tournesol', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[couukaypjdnyt] + 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', + 'couukaypjdnyt', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Solar flood light App panel', + 'model_id': 'pyakuuoc', + 'name': 'Solar zijpad', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[cq4hzlrnqn4qi0mqzc] + 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', + 'cq4hzlrnqn4qi0mqzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart plug', + 'model_id': 'qm0iq4nqnrlzh4qc', + 'name': 'Elivco Kitchen Socket', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[cwwk68dyfsh2eqi4jbqr] + 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', + 'cwwk68dyfsh2eqi4jbqr', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Gas sensor', + 'model_id': '4iqe2hsfyd86kwwc', + 'name': 'Gas sensor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[dke76hazlc] + 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', + 'dke76hazlc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'AM43拉绳电机-Zigbee', + 'model_id': 'zah67ekd', + 'name': 'Kitchen Blinds', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[dn7cjik6kw] + 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', + 'dn7cjik6kw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Thermostat Tervix Pro Line ZigBee color', + 'model_id': '6kijc7nd', + 'name': 'Кабінет', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[dt4whlrosmnldadvtk] + 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', + 'dt4whlrosmnldadvtk', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'YFA-05C', + 'model_id': 'vdadlnmsorlhw4td', + 'name': 'Sove', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[e2sbdwuga5jorvejtkdy] + 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', + 'e2sbdwuga5jorvejtkdy', + ), + }), + '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, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[eway2kw92ncuecarzc] + 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', + 'eway2kw92ncuecarzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Inline Switch 6000HA', + 'model_id': 'raceucn29wk2yawe', + 'name': 'Bathroom Mirror', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[fasvixqysw1lxvjprd] + 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', + 'fasvixqysw1lxvjprd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Sunbeam Bedding', + 'model_id': 'pjvxl1wsyqxivsaf', + 'name': 'Sunbeam Bedding', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[fbya6s6rhaoyvl8hqgcwy] + 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', + 'fbya6s6rhaoyvl8hqgcwy', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Tank A Level', + 'model_id': 'h8lvyoahr6s6aybf', + 'name': 'Rainwater Tank Level', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[fcdadqsiax2gvnt0qld] + 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', + 'fcdadqsiax2gvnt0qld', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '一路带计量磁保持通断器', + 'model_id': '0tnvg2xaisqdadcf', + 'name': '一路带计量磁保持通断器', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[fjdyw5ld2f5f5ddsps] + 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', + 'fjdyw5ld2f5f5ddsps', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Security Camera', + 'model_id': 'sdd5f5f2dl5wydjf', + 'name': 'C9', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ftvxinxevpy21tbelc] + 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', + 'ftvxinxevpy21tbelc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Blinds', + 'model_id': 'ebt12ypvexnixvtf', + 'name': 'Kitchen Blinds', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[fvywp3b5mu4zay8lgkxw] + 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', + 'fvywp3b5mu4zay8lgkxw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Wireless Switch', + 'model_id': 'l8yaz4um5b3pwyvf', + 'name': 'Bathroom Smart Switch', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[g0edqq0wzc] + 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', + 'g0edqq0wzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart plug', + 'model_id': 'w0qqde0g', + 'name': 'Lave linge', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[g1efxsqnp33cg8r3lc] + 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', + 'g1efxsqnp33cg8r3lc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Blinds Controller', + 'model_id': '3r8gc33pnqsxfe1g', + 'name': 'Lounge Dark Blind', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[g1fmm26qhhrimmbitk] + 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', + 'g1fmm26qhhrimmbitk', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'T platform model-USB ', + 'model_id': 'ibmmirhhq62mmf1g', + 'name': 'Master Bedroom AC', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[g5uso5ajgkxw] + 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', + 'g5uso5ajgkxw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'ZC-YED-一键无线开关', + 'model_id': 'ja5osu5g', + 'name': 'Bouton tempo extérieur', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[g7af6lrt4miugbstcp] + 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', + 'g7af6lrt4miugbstcp', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Power Strip', + 'model_id': 'tsbguim4trl6fa7g', + 'name': 'Keller', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ggwxkj8bwn5y63flgcdsw] + 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', + 'ggwxkj8bwn5y63flgcdsw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'T & H Sensor', + 'model_id': 'lf36y5nwb8jkxwgg', + 'name': 'Greenhouse', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[gjnpc0eojd] + 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', + 'gjnpc0eojd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Lighting', + 'model_id': 'oe0cpnjg', + 'name': 'Front right Lighting trap', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[gluaktf5gk] + 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', + 'gluaktf5gk', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '1Gang Zigbee Switch', + 'model_id': '5ftkaulg', + 'name': 'bathroom light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[gt1q9tldv1opojrtcp] + 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', + 'gt1q9tldv1opojrtcp', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Garden Spike(FR)', + 'model_id': 'trjopo1vdlt9q1tg', + 'name': 'Terras', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[gvxxy4jitzltz5xhscm] + 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', + 'gvxxy4jitzltz5xhscm', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Door Detector', + 'model_id': 'hx5ztlztij4yxxvg', + 'name': 'Steel cage door', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[hfqeljop3aihnm73zc] + 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', + 'hfqeljop3aihnm73zc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'SP111', + 'model_id': '37mnhia3pojleqfh', + 'name': 'Sapphire ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[hkm4px9ohzozxma3rip] + 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', + 'hkm4px9ohzozxma3rip', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Motion Sensor', + 'model_id': '3amxzozho9xp4mkh', + 'name': 'rat trap hedge', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[hxbonj4yzc] + 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', + 'hxbonj4yzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '【通用接入】1路插座', + 'model_id': 'y4jnobxh', + 'name': 'AuVeLiCo', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[hyda5jsihokacvaqjzm] + 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', + 'hyda5jsihokacvaqjzm', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Sous Vide', + 'model_id': 'qavcakohisj5adyh', + 'name': 'Sous Vide', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[hz4pau766eavmxhqsc] + 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', + 'hz4pau766eavmxhqsc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '', + 'model_id': 'qhxmvae667uap4zh', + 'name': 'DryFix', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[iaagy4qigcdsw] + 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', + 'iaagy4qigcdsw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Temperature Sensor', + 'model_id': 'iq4ygaai', + 'name': 'Bassin', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ifzgvpgoodrfw2aksc] + 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', + 'ifzgvpgoodrfw2aksc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Emma Dehumidifier - eeese air care', + 'model_id': 'ka2wfrdoogpvgzfi', + 'name': 'Dehumidifer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ijne16zv8vpqmubnjd] + 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', + 'ijne16zv8vpqmubnjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LSC smart GU10', + 'model_id': 'nbumqpv8vz61enji', + 'name': 'b2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ijzjlqwmv1blwe0gsf] + 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', + 'ijzjlqwmv1blwe0gsf', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Ceiling Fan With Light', + 'model_id': 'g0ewlb1vmwqljzji', + 'name': 'Ceiling Fan With Light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[iks13mcaiyie3rryjb2oc] + 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', + 'iks13mcaiyie3rryjb2oc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'AIR_DETECTOR ', + 'model_id': 'yrr3eiyiacm31ski', + 'name': 'AQI', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ilms5pwjzzsxuxmvsc] + 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', + 'ilms5pwjzzsxuxmvsc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'the Smart Dry Plus™ Connect Dehumidifier ', + 'model_id': 'vmxuxszzjwp5smli', + 'name': 'Dehumidifier ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[iomszlsve0yyzkfwqswwc] + 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', + 'iomszlsve0yyzkfwqswwc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Cleverio PF100', + 'model_id': 'wfkzyy0evslzsmoi', + 'name': 'Cleverio PF100', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[j6mn1t4ut5end6ifkw] + 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', + 'j6mn1t4ut5end6ifkw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'WiFi Smart Gas Boiler Thermostat ', + 'model_id': 'fi6dne5tu4t1nm6j', + 'name': 'WiFi Smart Gas Boiler Thermostat ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[jfpdpavoqgoqsn3cjd] + 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', + 'jfpdpavoqgoqsn3cjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'RGBstriplight', + 'model_id': 'c3nsqogqovapdpfj', + 'name': 'Arbeitszimmer led', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[jfydgffzmhjed9fgjbwy] + 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', + 'jfydgffzmhjed9fgjbwy', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Smoke Alarm', + 'model_id': 'gf9dejhmzffgdyfj', + 'name': ' Smoke detector upstairs ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[jlduh7vigcdsw] + 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', + 'jlduh7vigcdsw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Bluetooth Temperature Humidity Sensor', + 'model_id': 'iv7hudlj', + 'name': 'Basement temperature', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[jm2fsqtzuhqtbo5ykw] + 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', + 'jm2fsqtzuhqtbo5ykw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart', + 'model_id': 'y5obtqhuztqsf2mj', + 'name': 'Term - Prizemi', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[kcdngswaxs8hm52bnocfw] + 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', + 'kcdngswaxs8hm52bnocfw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'ZigBee Gateway (unsupported)', + 'model_id': 'b25mh8sxawsgndck', + 'name': 'ZigBee Gateway', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[kffnst1epj6vr8xnzc] + 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', + 'kffnst1epj6vr8xnzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart plug', + 'model_id': 'nx8rv6jpe1tsnffk', + 'name': 'Spot 1', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[kjr0pqg7eunn4vlujbgs] + 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', + 'kjr0pqg7eunn4vlujbgs', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Siren Sensor', + 'model_id': 'ulv4nnue7gqp0rjk', + 'name': 'Siren veranda ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[kkande5hk6sfdkoxjd] + 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', + 'kkande5hk6sfdkoxjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LSC-G125-Gold ', + 'model_id': 'xokdfs6kh5ednakk', + 'name': 'ERKER 1-Gold ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[klgxmpwvdhw7tzs8jd] + 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', + 'klgxmpwvdhw7tzs8jd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Light Bulb', + 'model_id': '8szt7whdvwpmxglk', + 'name': 'Porch light E', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ksy8guiy64acbbpnqkynw] + 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', + 'ksy8guiy64acbbpnqkynw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart IR (unsupported)', + 'model_id': 'npbbca46yiug8ysk', + 'name': 'Bedroom IR', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[kta28zbwj6u0xa6lbsgy] + 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', + 'kta28zbwj6u0xa6lbsgy', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '水泵 (unsupported)', + 'model_id': 'l6ax0u6jwbz82atk', + 'name': 'Pond', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[kvnsoqyfltmf0bknzc] + 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', + 'kvnsoqyfltmf0bknzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Konyks Pluviose Easy EU', + 'model_id': 'nkb0fmtlfyqosnvk', + 'name': 'Bassin', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[kx8dncf1qzkfs] + 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', + 'kx8dncf1qzkfs', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Valve Controller', + 'model_id': '1fcnd8xk', + 'name': 'Valve Controller 2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[kxwleaa2sph] + 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', + 'kxwleaa2sph', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Human presence sensor', + 'model_id': '2aaelwxk', + 'name': 'Human presence Office', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[kxxrbv93k2vvkconqdt] + 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', + 'kxxrbv93k2vvkconqdt', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '6 Switch Smart RetroFit Module', + 'model_id': 'nockvv2k39vbrxxk', + 'name': 'Seating side 6-ch Smart Switch ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[l8uxezzkc7c5a0jhzc] + 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', + 'l8uxezzkc7c5a0jhzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart plug', + 'model_id': 'hj0a5c7ckzzexu8l', + 'name': 'droger', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[lflvu8cazha8af9jsk] + 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', + 'lflvu8cazha8af9jsk', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Tower Fan CA-407G Smart', + 'model_id': 'j9fa8ahzac8uvlfl', + 'name': 'Tower Fan CA-407G Smart', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[mgcpxpmovasazerdps] + 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', + 'mgcpxpmovasazerdps', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Indoor camera ', + 'model_id': 'drezasavompxpcgm', + 'name': 'CAM GARAGE', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[mpowx36sgqexmtes2gw] + 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', + 'mpowx36sgqexmtes2gw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '', + 'model_id': 'setmxeqgs63xwopm', + 'name': 'Gateway', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[mvsdcwtskkezlnw5tk] + 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', + 'mvsdcwtskkezlnw5tk', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '移动空调 YPK--(双模+蓝牙)低功耗', + 'model_id': '5wnlzekkstwcdsvm', + 'name': 'Air Conditioner', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[mwsaod7fa3gjyh6ids] + 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', + 'mwsaod7fa3gjyh6ids', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'E20', + 'model_id': 'i6hyjg3af7doaswm', + 'name': 'Hoover', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ncl7oi5d6hqmf1g0zc] + 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', + 'ncl7oi5d6hqmf1g0zc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Mini Smart Plug', + 'model_id': '0g1fmqh6d5io7lcn', + 'name': 'Apollo light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ngcubvaqoraolsmtjd] + 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', + 'ngcubvaqoraolsmtjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'G95-Filament', + 'model_id': 'tmsloaroqavbucgn', + 'name': 'Pokerlamp 1', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[nnqlg0rxryraf8ezbdnz] + 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', + 'nnqlg0rxryraf8ezbdnz', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'PJ2101A 1P WiFi Smart Meter ', + 'model_id': 'ze8faryrxr0glqnn', + 'name': 'Meter', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[nr26obpclc] + 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', + 'nr26obpclc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'curtain robot', + 'model_id': 'cpbo62rn', + 'name': 'blinds', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[nt3mpibadxfqkegldyg] + 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', + 'nt3mpibadxfqkegldyg', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Colorful PIR Night Light', + 'model_id': 'lgekqfxdabipm3tn', + 'name': 'Colorful PIR Night Light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[nxdcy0uidplnhkazjd] + 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', + 'nxdcy0uidplnhkazjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LED SMART', + 'model_id': 'zakhnlpdiu0ycdxn', + 'name': 'Stoel', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[o71einxvuuktuljcjbwy] + 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', + 'o71einxvuuktuljcjbwy', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smoke Alarm', + 'model_id': 'cjlutkuuvxnie17o', + 'name': 'Rauchmelder Alexsandro ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[obb7p55c0us6rdxkqld] + 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', + 'obb7p55c0us6rdxkqld', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Metering_3PN_WiFi', + 'model_id': 'kxdr6su0c55p7bbo', + 'name': 'Metering_3PN_WiFi_stable', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ohefbbk9gcdl] + 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', + 'ohefbbk9gcdl', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Luminance sensor', + 'model_id': '9kbbfeho', + 'name': 'Luminosité', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ol8xwtcj42eg18bdbrnz] + 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', + 'ol8xwtcj42eg18bdbrnz', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Heat Pump', + 'model_id': 'db81ge24jctwx8lo', + 'name': 'Hot Water Heat Pump', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[oqyhsaqwsph] + 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', + 'oqyhsaqwsph', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Soil moisture sensor', + 'model_id': 'wqashyqo', + 'name': 'Soil moisture sensor #1', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[orotles4ucq8rxwn2gw] + 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', + 'orotles4ucq8rxwn2gw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'X5', + 'model_id': 'nwxr8qcu4seltoro', + 'name': 'X5 Zigbee Gateway', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[owozxdzgbibizu4sjk] + 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', + 'owozxdzgbibizu4sjk', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '', + 'model_id': 's4uzibibgzdxzowo', + 'name': 'ION1000PRO', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[p8xoxccrjbwy] + 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', + 'p8xoxccrjbwy', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smoke Alarm', + 'model_id': 'rccxox8p', + 'name': 'Smoke Alarm', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[pdasfna8fswh4a0tzc] + 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', + 'pdasfna8fswh4a0tzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Plug ', + 'model_id': 't0a4hwsf8anfsadp', + 'name': 'wallwasher front', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[pfhwb1v3i7cifa2tcp] + 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', + 'pfhwb1v3i7cifa2tcp', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Garden Spike(EU)', + 'model_id': 't2afic7i3v1bwhfp', + 'name': 'Bubbelbad', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ppgdpsq1xaxlyzryjk] + 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', + 'ppgdpsq1xaxlyzryjk', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '40" Bladeless Tower Fan', + 'model_id': 'yrzylxax1qspdgpp', + 'name': 'Bree', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[pykascx9yfqrxtbgzc] + 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', + 'pykascx9yfqrxtbgzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Plug+', + 'model_id': 'gbtxrqfy9xcsakyp', + 'name': '3DPrinter', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[pz2xuth8hczv6zrwzc] + 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', + 'pz2xuth8hczv6zrwzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'WiFi Plug', + 'model_id': 'wrz6vzch8htux2zp', + 'name': 'Elivco TV', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[q304vac40br8nlkajsywc] + 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', + 'q304vac40br8nlkajsywc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Pet Water Fountain', + 'model_id': 'akln8rb04cav403q', + 'name': 'Water Fountain', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[qi94v9dmdx4fkpncqld] + 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', + 'qi94v9dmdx4fkpncqld', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Breaker', + 'model_id': 'cnpkf4xdmd9v49iq', + 'name': '断路器HA', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[queafegmhhmtivdxjd] + 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', + 'queafegmhhmtivdxjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'GU10 Smart Bulb', + 'model_id': 'xdvitmhhmgefaeuq', + 'name': 'druckerhell', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[qyy1auihjyoogvb7zdccq] + 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', + 'qyy1auihjyoogvb7zdccq', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'AC charging control box', + 'model_id': '7bvgooyjhiua1yyq', + 'name': 'AC charging control box', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[r4yrlr705ei31ikmjd] + 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', + 'r4yrlr705ei31ikmjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Light Bulb', + 'model_id': 'mki13ie507rlry4r', + 'name': 'Garage light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[rdq0bn4dzuwx2qfujd] + 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', + 'rdq0bn4dzuwx2qfujd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Ceiling Lamp', + 'model_id': 'ufq2xwuzd4nb0qdr', + 'name': 'Sjiethoes', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[rl39uwgaqwjwc] + 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', + 'rl39uwgaqwjwc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Odor Eliminator-Pro', + 'model_id': 'agwu93lr', + 'name': 'Smart Odor Eliminator-Pro', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[rojky4l6yyjreeilnocfw] + 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', + 'rojky4l6yyjreeilnocfw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Zigbee Smart Gateway (unsupported)', + 'model_id': 'lieerjyy6l4ykjor', + 'name': 'Zigbee Gateway', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[rsjdwgnbqky] + 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', + 'rsjdwgnbqky', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Remote controller (unsupported)', + 'model_id': 'bngwdjsr', + 'name': 'Télécommande lumières ZigBee', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[rwp6kdezm97s2nktzc] + 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', + 'rwp6kdezm97s2nktzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Socket ', + 'model_id': 'tkn2s79mzedk6pwr', + 'name': 'Weihnachtsmann ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[s3zzjdcfrip] + 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', + 's3zzjdcfrip', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Motion sensor', + 'model_id': 'fcdjzz3s', + 'name': 'Motion sensor lidl zigbee', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[sb3zdertrw50bgogkw] + 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', + 'sb3zdertrw50bgogkw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'smart thermostats', + 'model_id': 'gogb05wrtredz3bs', + 'name': 'smart thermostats', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[sdq2flqkq0lblcah2gw] + 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', + 'sdq2flqkq0lblcah2gw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Multi-mode Gateway', + 'model_id': 'haclbl0qkqlf2qds', + 'name': 'Home Gateway', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[sifg4pfqsylsayg0jd] + 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', + 'sifg4pfqsylsayg0jd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '', + 'model_id': '0gyaslysqfp4gfis', + 'name': 'Study 1', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[sj55nxhjftilowkejd] + 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', + 'sj55nxhjftilowkejd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LSC Smart Connect GU10 RGB+CCT', + 'model_id': 'ekwolitfjhxn55js', + 'name': 'ab6', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[snbu4b3vekhywztwqgcwy] + 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', + 'snbu4b3vekhywztwqgcwy', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Tank A Level', + 'model_id': 'wtzwyhkev3b4ubns', + 'name': 'House Water Level', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[svjjuwykgijjedurps] + 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', + 'svjjuwykgijjedurps', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LSC PTZ Camera', + 'model_id': 'rudejjigkywujjvs', + 'name': 'Bürocam', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[swhtzki3qrz5ydchjboc] + 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', + 'swhtzki3qrz5ydchjboc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'WIFI smart CO alarm', + 'model_id': 'hcdy5zrq3ikzthws', + 'name': 'Smogo', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[t5zosev6h6wmwyrajbwy] + 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', + 't5zosev6h6wmwyrajbwy', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smoke Alarm ', + 'model_id': 'arywmw6h6vesoz5t', + 'name': 'Rauchmelder Drucker', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[t7bvnnvplkwhdqm9qtn] + 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', + 't7bvnnvplkwhdqm9qtn', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'TION Breezer Bio X (unsupported)', + 'model_id': '9mqdhwklpvnnvb7t', + 'name': 'Бризер Зал', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[tcdk0skzcpisexj2zc] + 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', + 'tcdk0skzcpisexj2zc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Dual channel metering', + 'model_id': '2jxesipczks0kdct', + 'name': 'HVAC Meter', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[thdfxdqqlc] + 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', + 'thdfxdqqlc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Blinds Drive-BLE', + 'model_id': 'qqdxfdht', + 'name': 'bedroom blinds', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[trffx1ktlyu3tnmljd] + 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', + 'trffx1ktlyu3tnmljd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LED SMART', + 'model_id': 'lmnt3uyltk1xffrt', + 'name': 'DirectietKamer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[tskafaotnfigad6oqzkfs] + 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', + 'tskafaotnfigad6oqzkfs', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Valve Controller', + 'model_id': 'o6dagifntoafakst', + 'name': 'Sprinkler Cesare', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[tvgoe1s3fabebcskjbwy] + 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', + 'tvgoe1s3fabebcskjbwy', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'WIFI Smoke alarm', + 'model_id': 'kscbebaf3s1eogvt', + 'name': 'WIFI Smoke alarm', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[uBLyTOvlhoRWXKjrps] + 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', + 'uBLyTOvlhoRWXKjrps', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Indoor cam Pan/Tilt ', + 'model_id': 'rjKXWRohlvOTyLBu', + 'name': 'CAM PORCH', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[uew54dymycjwz] + 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', + 'uew54dymycjwz', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Soil sensor', + 'model_id': 'myd45weu', + 'name': 'Patates', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[urm7i0rtdlabqiqygcdsw] + 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', + 'urm7i0rtdlabqiqygcdsw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'WiFi Temperature & Humidity Sensor', + 'model_id': 'yqiqbaldtr0i7mru', + 'name': 'WiFi Temperature & Humidity Sensor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[uvh6oeqrfliovfiwzc] + 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', + 'uvh6oeqrfliovfiwzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Socket', + 'model_id': 'wifvoilfrqeo6hvu', + 'name': 'Licht drucker', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[vayhq2aj3p3z6y2ggcdsw] + 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', + 'vayhq2aj3p3z6y2ggcdsw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '温湿度传感器wifi', + 'model_id': 'g2y6z3p3ja2qhyav', + 'name': 'NP DownStairs North', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[vcrfgwvbuybgnj3zqld] + 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', + 'vcrfgwvbuybgnj3zqld', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Breaker', + 'model_id': 'z3jngbyubvwgfrcv', + 'name': 'Edesanya Energy', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[vnj3sa6mqahro6phjd] + 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', + 'vnj3sa6mqahro6phjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LED Strip Lights', + 'model_id': 'hp6orhaqm6as3jnv', + 'name': 'Master bedroom TV lights', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[vrhdtr5fawoiyth9qdt] + 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', + 'vrhdtr5fawoiyth9qdt', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '1-433', + 'model_id': '9htyiowaf5rtdhrv', + 'name': 'Framboisiers', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[vx2owjsg86g2ys93zc] + 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', + 'vx2owjsg86g2ys93zc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Ineox SP2', + 'model_id': '39sy2g68gsjwo2xv', + 'name': 'Ineox SP2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[vzu7lkknqjz] + 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', + 'vzu7lkknqjz', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Zigbee Repeater (unsupported)', + 'model_id': 'nkkl7uzv', + 'name': 'Zigby répéteur ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[w8oht6v8aauqa0y8jd] + 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', + 'w8oht6v8aauqa0y8jd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'A60 Clear', + 'model_id': '8y0aquaa8v6tho8w', + 'name': 'dressoir spot', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[wc6mumew8inrivi9zc] + 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', + 'wc6mumew8inrivi9zc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Socket', + 'model_id': '9ivirni8wemum6cw', + 'name': 'Garáž čerpadlo', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[x4nogasbi8ggpb3lcd] + 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', + 'x4nogasbi8ggpb3lcd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LSC Party String Light RGBIC+CCT ', + 'model_id': 'l3bpgg8ibsagon4x', + 'name': 'LSC Party String Light RGBIC+CCT ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[x7quooqakw] + 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', + 'x7quooqakw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'T7-Air conditioner thermostat(ZIGBEE)', + 'model_id': 'aqoouq7x', + 'name': 'Clima cucina', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[xenxir4a0tn0p1qcqdt] + 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', + 'xenxir4a0tn0p1qcqdt', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '4-433', + 'model_id': 'cq1p0nt0a4rixnex', + 'name': '4-433', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[yky6kunazmaitupzjd] + 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', + 'yky6kunazmaitupzjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LSC Floodlight', + 'model_id': 'zputiamzanuk6yky', + 'name': 'Floodlight', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[yo2karkjuhzztxsfjk] + 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', + 'yo2karkjuhzztxsfjk', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '', + 'model_id': 'fsxtzzhujkrak2oy', + 'name': 'Kalado Air Purifier', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[yuanswy6scm] + 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', + 'yuanswy6scm', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Contact Sensor', + 'model_id': '6ywsnauy', + 'name': 'Fenêtre cuisine', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[z7cu5t8bl9tt9fabjd] + 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', + 'z7cu5t8bl9tt9fabjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LED SMART', + 'model_id': 'baf9tt9lb8t5uc7z', + 'name': 'Pokerlamp 2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[z8woiryqydmzonjdjd] + 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', + 'z8woiryqydmzonjdjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Candle RGB-CCT', + 'model_id': 'djnozmdyqyriow8z', + 'name': 'Fakkel 8', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[zaszonjgzc] + 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', + 'zaszonjgzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart plug', + 'model_id': 'gjnozsaz', + 'name': 'Raspy4 - Home Assistant', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[zf8vgiwoa07jwegtjd] + 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', + 'zf8vgiwoa07jwegtjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'RGBC Smart Bulb', + 'model_id': 'tgewj70aowigv8fz', + 'name': 'Stairs', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[zfHZQ7tZUBxAWjACjk] + 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', + 'zfHZQ7tZUBxAWjACjk', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'air purifier', + 'model_id': 'CAjWAxBUZt7QZHfz', + 'name': 'HL400', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[zjh9xhtm3gibs9kizc] + 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', + 'zjh9xhtm3gibs9kizc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Aubess Smart\xa0Socket EM', + 'model_id': 'ik9sbig3mthx9hjz', + 'name': 'Aubess Washing Machine', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[zoytcemodrn39zqwrip] + 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', + 'zoytcemodrn39zqwrip', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart PIR sensor', + 'model_id': 'wqz93nrdomectyoz', + 'name': 'PIR outside stairs', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[zrrraytdoanz33rlds] + 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', + 'zrrraytdoanz33rlds', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'V20', + 'model_id': 'lr33znaodtyarrrz', + 'name': 'V20', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[zspxfhsvgn2hgtndzc] + 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', + 'zspxfhsvgn2hgtndzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Plug', + 'model_id': 'dntgh2ngvshfxpsz', + 'name': 'fakkel veranda ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[zyutbek7wdm1b4cgzckw] + 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', + 'zyutbek7wdm1b4cgzckw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '4-TH', + 'model_id': 'gc4b1mdw7kebtuyz', + 'name': 'pid_relay_2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) # --- diff --git a/tests/components/tuya/test_init.py b/tests/components/tuya/test_init.py index c0311198fdd..545a5a7f07c 100644 --- a/tests/components/tuya/test_init.py +++ b/tests/components/tuya/test_init.py @@ -2,7 +2,6 @@ from __future__ import annotations -import pytest from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice @@ -16,30 +15,40 @@ from . import DEVICE_MOCKS, initialize_entry from tests.common import MockConfigEntry, async_load_json_object_fixture -@pytest.mark.parametrize("mock_device_code", ["ydkt_jevroj5aguwdbs2e"]) -async def test_unsupported_device( +async def test_device_registry( hass: HomeAssistant, mock_manager: ManagerCompat, mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, + mock_devices: CustomerDevice, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test unsupported device.""" + """Validate device registry snapshots for all devices, including unsupported ones.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) - # 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 + device_registry_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id ) + # Ensure the device registry contains same amount as DEVICE_MOCKS + assert len(device_registry_entries) == len(DEVICE_MOCKS) + + for device_registry_entry in device_registry_entries: + assert device_registry_entry == snapshot( + name=list(device_registry_entry.identifiers)[0][1] + ) + + # Ensure model is suffixed with "(unsupported)" when no entities are generated + assert (" (unsupported)" in device_registry_entry.model) == ( + not er.async_entries_for_device( + entity_registry, + device_registry_entry.id, + include_disabled_entities=True, + ) + ) + async def test_fixtures_valid(hass: HomeAssistant) -> None: """Ensure Tuya fixture files are valid.""" From 072ae2b95588f2dc5a1ae57c444762d95d7ce94a Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Tue, 12 Aug 2025 15:19:15 +0300 Subject: [PATCH 0947/1113] ruuvitag_ble: add new sensors (#150435) Co-authored-by: Joostlek --- .../components/ruuvitag_ble/sensor.py | 115 +- .../components/ruuvitag_ble/strings.json | 25 + tests/components/ruuvitag_ble/fixtures.py | 13 +- .../ruuvitag_ble/snapshots/test_sensor.ambr | 1013 +++++++++++++++++ .../ruuvitag_ble/test_config_flow.py | 38 +- tests/components/ruuvitag_ble/test_sensor.py | 46 +- 6 files changed, 1167 insertions(+), 83 deletions(-) create mode 100644 tests/components/ruuvitag_ble/snapshots/test_sensor.ambr diff --git a/homeassistant/components/ruuvitag_ble/sensor.py b/homeassistant/components/ruuvitag_ble/sensor.py index 57248d547ba..44311fd12eb 100644 --- a/homeassistant/components/ruuvitag_ble/sensor.py +++ b/homeassistant/components/ruuvitag_ble/sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from sensor_state_data import ( DeviceKey, - SensorDescription, SensorDeviceClass as SSDSensorDeviceClass, SensorUpdate, Units, @@ -32,53 +31,108 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN SENSOR_DESCRIPTIONS = { - (SSDSensorDeviceClass.TEMPERATURE, Units.TEMP_CELSIUS): SensorEntityDescription( + "temperature": SensorEntityDescription( key=f"{SSDSensorDeviceClass.TEMPERATURE}_{Units.TEMP_CELSIUS}", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), - (SSDSensorDeviceClass.HUMIDITY, Units.PERCENTAGE): SensorEntityDescription( + "humidity": SensorEntityDescription( key=f"{SSDSensorDeviceClass.HUMIDITY}_{Units.PERCENTAGE}", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - (SSDSensorDeviceClass.PRESSURE, Units.PRESSURE_HPA): SensorEntityDescription( + "pressure": SensorEntityDescription( key=f"{SSDSensorDeviceClass.PRESSURE}_{Units.PRESSURE_HPA}", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, ), - ( - SSDSensorDeviceClass.VOLTAGE, - Units.ELECTRIC_POTENTIAL_MILLIVOLT, - ): SensorEntityDescription( + "voltage": SensorEntityDescription( key=f"{SSDSensorDeviceClass.VOLTAGE}_{Units.ELECTRIC_POTENTIAL_MILLIVOLT}", native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ), - ( - SSDSensorDeviceClass.SIGNAL_STRENGTH, - Units.SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - ): SensorEntityDescription( + "signal_strength": SensorEntityDescription( key=f"{SSDSensorDeviceClass.SIGNAL_STRENGTH}_{Units.SIGNAL_STRENGTH_DECIBELS_MILLIWATT}", device_class=SensorDeviceClass.SIGNAL_STRENGTH, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - (SSDSensorDeviceClass.COUNT, None): SensorEntityDescription( + "movement_counter": SensorEntityDescription( key="movement_counter", + translation_key="movement_counter", state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), + # Acceleration keys exported in newer versions of ruuvitag-ble + "acceleration_x": SensorEntityDescription( + key=f"acceleration_x_{Units.ACCELERATION_METERS_PER_SQUARE_SECOND}", + translation_key="acceleration_x", + native_unit_of_measurement=Units.ACCELERATION_METERS_PER_SQUARE_SECOND, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "acceleration_y": SensorEntityDescription( + key=f"acceleration_y_{Units.ACCELERATION_METERS_PER_SQUARE_SECOND}", + translation_key="acceleration_y", + native_unit_of_measurement=Units.ACCELERATION_METERS_PER_SQUARE_SECOND, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "acceleration_z": SensorEntityDescription( + key=f"acceleration_z_{Units.ACCELERATION_METERS_PER_SQUARE_SECOND}", + translation_key="acceleration_z", + native_unit_of_measurement=Units.ACCELERATION_METERS_PER_SQUARE_SECOND, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "acceleration_total": SensorEntityDescription( + key=f"acceleration_total_{Units.ACCELERATION_METERS_PER_SQUARE_SECOND}", + translation_key="acceleration_total", + native_unit_of_measurement=Units.ACCELERATION_METERS_PER_SQUARE_SECOND, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + # Keys exported for dataformat 06 sensors in newer versions of ruuvitag-ble + "pm25": SensorEntityDescription( + key=f"{SSDSensorDeviceClass.PM25}_{Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}", + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + "carbon_dioxide": SensorEntityDescription( + key=f"{SSDSensorDeviceClass.CO2}_{Units.CONCENTRATION_PARTS_PER_MILLION}", + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=Units.CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ), + "illuminance": SensorEntityDescription( + key=f"{SSDSensorDeviceClass.ILLUMINANCE}_{Units.LIGHT_LUX}", + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=Units.LIGHT_LUX, + state_class=SensorStateClass.MEASUREMENT, + ), + "voc_index": SensorEntityDescription( + key="voc_index", + translation_key="voc_index", + state_class=SensorStateClass.MEASUREMENT, + ), + "nox_index": SensorEntityDescription( + key="nox_index", + translation_key="nox_index", + state_class=SensorStateClass.MEASUREMENT, + ), } @@ -89,37 +143,28 @@ def _device_key_to_bluetooth_entity_key( return PassiveBluetoothEntityKey(device_key.key, device_key.device_id) -def _to_sensor_key( - description: SensorDescription, -) -> tuple[SSDSensorDeviceClass, Units | None]: - assert description.device_class is not None - return (description.device_class, description.native_unit_of_measurement) - - def sensor_update_to_bluetooth_data_update( sensor_update: SensorUpdate, ) -> PassiveBluetoothDataUpdate: """Convert a sensor update to a bluetooth data update.""" + entity_descriptions: dict[PassiveBluetoothEntityKey, EntityDescription] = {} + entity_data = {} + for device_key, sensor_values in sensor_update.entity_values.items(): + bek = _device_key_to_bluetooth_entity_key(device_key) + entity_data[bek] = sensor_values.native_value + for device_key in sensor_update.entity_descriptions: + bek = _device_key_to_bluetooth_entity_key(device_key) + if sk_description := SENSOR_DESCRIPTIONS.get(device_key.key): + entity_descriptions[bek] = sk_description + return PassiveBluetoothDataUpdate( devices={ device_id: sensor_device_info_to_hass_device_info(device_info) for device_id, device_info in sensor_update.devices.items() }, - entity_descriptions={ - _device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[ - _to_sensor_key(description) - ] - for device_key, description in sensor_update.entity_descriptions.items() - if _to_sensor_key(description) in SENSOR_DESCRIPTIONS - }, - entity_data={ - _device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value - for device_key, sensor_values in sensor_update.entity_values.items() - }, - entity_names={ - _device_key_to_bluetooth_entity_key(device_key): sensor_values.name - for device_key, sensor_values in sensor_update.entity_values.items() - }, + entity_descriptions=entity_descriptions, + entity_data=entity_data, + entity_names={}, ) diff --git a/homeassistant/components/ruuvitag_ble/strings.json b/homeassistant/components/ruuvitag_ble/strings.json index 16a80220a20..0abb8343c65 100644 --- a/homeassistant/components/ruuvitag_ble/strings.json +++ b/homeassistant/components/ruuvitag_ble/strings.json @@ -18,5 +18,30 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "acceleration_total": { + "name": "Acceleration total" + }, + "acceleration_x": { + "name": "Acceleration X" + }, + "acceleration_y": { + "name": "Acceleration Y" + }, + "acceleration_z": { + "name": "Acceleration Z" + }, + "movement_counter": { + "name": "Movement counter" + }, + "nox_index": { + "name": "NOx index" + }, + "voc_index": { + "name": "VOC index" + } + } } } diff --git a/tests/components/ruuvitag_ble/fixtures.py b/tests/components/ruuvitag_ble/fixtures.py index 5d6ac9ea470..94ed1e00331 100644 --- a/tests/components/ruuvitag_ble/fixtures.py +++ b/tests/components/ruuvitag_ble/fixtures.py @@ -12,7 +12,7 @@ NOT_RUUVITAG_SERVICE_INFO = BluetoothServiceInfo( source="local", ) -RUUVITAG_SERVICE_INFO = BluetoothServiceInfo( +RUUVI_V5_SERVICE_INFO = BluetoothServiceInfo( name="RuuviTag 0911", address="01:03:05:07:09:11", # Ignored (the payload encodes the correct MAC) rssi=-60, @@ -23,5 +23,16 @@ RUUVITAG_SERVICE_INFO = BluetoothServiceInfo( service_uuids=[], source="local", ) +RUUVI_V6_SERVICE_INFO = BluetoothServiceInfo( + name="Ruuvi 1234", + address="01:03:05:07:12:34", # Ignored (the payload encodes the correct MAC) + rssi=-60, + manufacturer_data={ + 1177: b"\x06\x17\x0cVh\xc7\x9e\x00p\x00\xc9\x05\x01\xd9J\xcd\x00L\x88O", + }, + service_data={}, + service_uuids=[], + source="local", +) CONFIGURED_NAME = "RuuviTag EFAF" CONFIGURED_PREFIX = "ruuvitag_efaf" diff --git a/tests/components/ruuvitag_ble/snapshots/test_sensor.ambr b/tests/components/ruuvitag_ble/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..2bdcb4f3a6a --- /dev/null +++ b/tests/components/ruuvitag_ble/snapshots/test_sensor.ambr @@ -0,0 +1,1013 @@ +# serializer version: 1 +# name: test_sensors[v5][sensor.ruuvitag_efaf_acceleration_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_efaf_acceleration_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Acceleration total', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'acceleration_total', + 'unique_id': '01:03:05:07:09:11-acceleration_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_acceleration_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'RuuviTag EFAF Acceleration total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_efaf_acceleration_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.82', + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_acceleration_x-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_efaf_acceleration_x', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Acceleration X', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'acceleration_x', + 'unique_id': '01:03:05:07:09:11-acceleration_x', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_acceleration_x-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'RuuviTag EFAF Acceleration X', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_efaf_acceleration_x', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-7.02', + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_acceleration_y-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_efaf_acceleration_y', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Acceleration Y', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'acceleration_y', + 'unique_id': '01:03:05:07:09:11-acceleration_y', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_acceleration_y-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'RuuviTag EFAF Acceleration Y', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_efaf_acceleration_y', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.39', + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_acceleration_z-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_efaf_acceleration_z', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Acceleration Z', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'acceleration_z', + 'unique_id': '01:03:05:07:09:11-acceleration_z', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_acceleration_z-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'RuuviTag EFAF Acceleration Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_efaf_acceleration_z', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-2.51', + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_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.ruuvitag_efaf_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': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:09:11-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'RuuviTag EFAF Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_efaf_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '61.84', + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_movement_counter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_efaf_movement_counter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Movement counter', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'movement_counter', + 'unique_id': '01:03:05:07:09:11-movement_counter', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_movement_counter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'RuuviTag EFAF Movement counter', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_efaf_movement_counter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '114', + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_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.ruuvitag_efaf_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': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:09:11-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'RuuviTag EFAF Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_efaf_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1013.54', + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_efaf_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:09:11-signal_strength', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'RuuviTag EFAF Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_efaf_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-60', + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_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.ruuvitag_efaf_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': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:09:11-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'RuuviTag EFAF Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_efaf_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.2', + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_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.ruuvitag_efaf_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': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:09:11-voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'RuuviTag EFAF Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_efaf_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2395', + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_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.ruuvitag_884f_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:12:34-carbon_dioxide', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'RuuviTag 884F Carbon dioxide', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_884f_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '201', + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_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.ruuvitag_884f_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': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:12:34-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'RuuviTag 884F Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_884f_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '55.3', + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_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.ruuvitag_884f_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': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:12:34-illuminance', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'RuuviTag 884F Illuminance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_884f_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13027', + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_nox_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.ruuvitag_884f_nox_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'NOx index', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'nox_index', + 'unique_id': '01:03:05:07:12:34-nox_index', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_nox_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'RuuviTag 884F NOx index', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_884f_nox_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_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.ruuvitag_884f_pm2_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': 'PM2.5', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:12:34-pm25', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'RuuviTag 884F PM2.5', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_884f_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.2', + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_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.ruuvitag_884f_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': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:12:34-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'RuuviTag 884F Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_884f_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1011.02', + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_884f_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:12:34-signal_strength', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'RuuviTag 884F Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_884f_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-60', + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_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.ruuvitag_884f_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': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:12:34-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'RuuviTag 884F Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_884f_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29.5', + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_voc_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.ruuvitag_884f_voc_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VOC index', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voc_index', + 'unique_id': '01:03:05:07:12:34-voc_index', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_voc_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'RuuviTag 884F VOC index', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_884f_voc_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- diff --git a/tests/components/ruuvitag_ble/test_config_flow.py b/tests/components/ruuvitag_ble/test_config_flow.py index 3414fa34536..5259511fc0f 100644 --- a/tests/components/ruuvitag_ble/test_config_flow.py +++ b/tests/components/ruuvitag_ble/test_config_flow.py @@ -9,7 +9,7 @@ from homeassistant.components.ruuvitag_ble.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .fixtures import CONFIGURED_NAME, NOT_RUUVITAG_SERVICE_INFO, RUUVITAG_SERVICE_INFO +from .fixtures import CONFIGURED_NAME, NOT_RUUVITAG_SERVICE_INFO, RUUVI_V5_SERVICE_INFO from tests.common import MockConfigEntry @@ -24,7 +24,7 @@ async def test_async_step_bluetooth_valid_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_BLUETOOTH}, - data=RUUVITAG_SERVICE_INFO, + data=RUUVI_V5_SERVICE_INFO, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "bluetooth_confirm" @@ -36,7 +36,7 @@ async def test_async_step_bluetooth_valid_device(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == CONFIGURED_NAME - assert result2["result"].unique_id == RUUVITAG_SERVICE_INFO.address + assert result2["result"].unique_id == RUUVI_V5_SERVICE_INFO.address async def test_async_step_bluetooth_not_ruuvitag(hass: HomeAssistant) -> None: @@ -64,7 +64,7 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: """Test setup from service info cache with devices found.""" with patch( "homeassistant.components.ruuvitag_ble.config_flow.async_discovered_service_info", - return_value=[RUUVITAG_SERVICE_INFO], + return_value=[RUUVI_V5_SERVICE_INFO], ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -77,18 +77,18 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"address": RUUVITAG_SERVICE_INFO.address}, + user_input={"address": RUUVI_V5_SERVICE_INFO.address}, ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == CONFIGURED_NAME - assert result2["result"].unique_id == RUUVITAG_SERVICE_INFO.address + assert result2["result"].unique_id == RUUVI_V5_SERVICE_INFO.address async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( "homeassistant.components.ruuvitag_ble.config_flow.async_discovered_service_info", - return_value=[RUUVITAG_SERVICE_INFO], + return_value=[RUUVI_V5_SERVICE_INFO], ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -99,7 +99,7 @@ async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) - entry = MockConfigEntry( domain=DOMAIN, - unique_id=RUUVITAG_SERVICE_INFO.address, + unique_id=RUUVI_V5_SERVICE_INFO.address, ) entry.add_to_hass(hass) @@ -108,7 +108,7 @@ async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) - ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"address": RUUVITAG_SERVICE_INFO.address}, + user_input={"address": RUUVI_V5_SERVICE_INFO.address}, ) assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" @@ -120,13 +120,13 @@ async def test_async_step_user_with_found_devices_already_setup( """Test setup from service info cache with devices found.""" entry = MockConfigEntry( domain=DOMAIN, - unique_id=RUUVITAG_SERVICE_INFO.address, + unique_id=RUUVI_V5_SERVICE_INFO.address, ) entry.add_to_hass(hass) with patch( "homeassistant.components.ruuvitag_ble.config_flow.async_discovered_service_info", - return_value=[RUUVITAG_SERVICE_INFO], + return_value=[RUUVI_V5_SERVICE_INFO], ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -140,14 +140,14 @@ async def test_async_step_bluetooth_devices_already_setup(hass: HomeAssistant) - """Test we can't start a flow if there is already a config entry.""" entry = MockConfigEntry( domain=DOMAIN, - unique_id=RUUVITAG_SERVICE_INFO.address, + unique_id=RUUVI_V5_SERVICE_INFO.address, ) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_BLUETOOTH}, - data=RUUVITAG_SERVICE_INFO, + data=RUUVI_V5_SERVICE_INFO, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -158,7 +158,7 @@ async def test_async_step_bluetooth_already_in_progress(hass: HomeAssistant) -> result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_BLUETOOTH}, - data=RUUVITAG_SERVICE_INFO, + data=RUUVI_V5_SERVICE_INFO, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "bluetooth_confirm" @@ -166,7 +166,7 @@ async def test_async_step_bluetooth_already_in_progress(hass: HomeAssistant) -> result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_BLUETOOTH}, - data=RUUVITAG_SERVICE_INFO, + data=RUUVI_V5_SERVICE_INFO, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_in_progress" @@ -179,14 +179,14 @@ async def test_async_step_user_takes_precedence_over_discovery( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_BLUETOOTH}, - data=RUUVITAG_SERVICE_INFO, + data=RUUVI_V5_SERVICE_INFO, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "bluetooth_confirm" with patch( "homeassistant.components.ruuvitag_ble.config_flow.async_discovered_service_info", - return_value=[RUUVITAG_SERVICE_INFO], + return_value=[RUUVI_V5_SERVICE_INFO], ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -199,9 +199,9 @@ async def test_async_step_user_takes_precedence_over_discovery( ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"address": RUUVITAG_SERVICE_INFO.address}, + user_input={"address": RUUVI_V5_SERVICE_INFO.address}, ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == CONFIGURED_NAME assert result2["data"] == {} - assert result2["result"].unique_id == RUUVITAG_SERVICE_INFO.address + assert result2["result"].unique_id == RUUVI_V5_SERVICE_INFO.address diff --git a/tests/components/ruuvitag_ble/test_sensor.py b/tests/components/ruuvitag_ble/test_sensor.py index 14826a692a6..edeb6a4c2b5 100644 --- a/tests/components/ruuvitag_ble/test_sensor.py +++ b/tests/components/ruuvitag_ble/test_sensor.py @@ -3,47 +3,37 @@ from __future__ import annotations import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.ruuvitag_ble.const import DOMAIN -from homeassistant.components.sensor import ATTR_STATE_CLASS -from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo -from .fixtures import CONFIGURED_NAME, CONFIGURED_PREFIX, RUUVITAG_SERVICE_INFO +from .fixtures import RUUVI_V5_SERVICE_INFO, RUUVI_V6_SERVICE_INFO -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform from tests.components.bluetooth import inject_bluetooth_service_info -@pytest.mark.usefixtures("enable_bluetooth") -async def test_sensors(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("enable_bluetooth", "entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "service_info", [RUUVI_V5_SERVICE_INFO, RUUVI_V6_SERVICE_INFO], ids=("v5", "v6") +) +async def test_sensors( + hass: HomeAssistant, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, + service_info: BluetoothServiceInfo, +) -> None: """Test the RuuviTag BLE sensors.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=RUUVITAG_SERVICE_INFO.address) + entry = MockConfigEntry(domain=DOMAIN, unique_id=service_info.address) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - - assert len(hass.states.async_all()) == 0 - inject_bluetooth_service_info( - hass, - RUUVITAG_SERVICE_INFO, - ) + inject_bluetooth_service_info(hass, service_info) await hass.async_block_till_done() - assert len(hass.states.async_all()) >= 4 - - for sensor, value, unit, state_class in ( - ("temperature", "7.2", "°C", "measurement"), - ("humidity", "61.84", "%", "measurement"), - ("pressure", "1013.54", "hPa", "measurement"), - ("voltage", "2395", "mV", "measurement"), - ): - state = hass.states.get(f"sensor.{CONFIGURED_PREFIX}_{sensor}") - assert state is not None - assert state.state == value - name_lower = state.attributes[ATTR_FRIENDLY_NAME].lower() - assert name_lower == f"{CONFIGURED_NAME} {sensor}".lower() - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == unit - assert state.attributes[ATTR_STATE_CLASS] == state_class + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() From 455cf2fb425c65adfa4595ef6ea0858e1d0db054 Mon Sep 17 00:00:00 2001 From: hanwg Date: Tue, 12 Aug 2025 21:24:13 +0800 Subject: [PATCH 0948/1113] Add notify platform for Telegram bot (#149853) Co-authored-by: Joost Lekkerkerker --- .../components/telegram_bot/__init__.py | 14 +++- .../components/telegram_bot/notify.py | 62 ++++++++++++++++ tests/components/telegram_bot/test_notify.py | 72 +++++++++++++++++++ 3 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/telegram_bot/notify.py create mode 100644 tests/components/telegram_bot/test_notify.py diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index cab147162aa..50c721e5f37 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -19,6 +19,7 @@ from homeassistant.const import ( CONF_PLATFORM, CONF_SOURCE, CONF_URL, + Platform, ) from homeassistant.core import ( HomeAssistant, @@ -291,6 +292,8 @@ MODULES: dict[str, ModuleType] = { PLATFORM_WEBHOOKS: webhooks, } +PLATFORMS: list[Platform] = [Platform.NOTIFY] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Telegram bot component.""" @@ -477,15 +480,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: TelegramBotConfigEntry) ) entry.runtime_data = notify_service + 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: TelegramBotConfigEntry) -> None: - """Handle options update.""" + """Handle config changes.""" entry.runtime_data.parse_mode = entry.options[ATTR_PARSER] + # reload entities + await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + async def async_unload_entry( hass: HomeAssistant, entry: TelegramBotConfigEntry @@ -494,4 +503,5 @@ async def async_unload_entry( # broadcast platform has no app if entry.runtime_data.app: await entry.runtime_data.app.shutdown() - return True + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/telegram_bot/notify.py b/homeassistant/components/telegram_bot/notify.py new file mode 100644 index 00000000000..822bd7b925d --- /dev/null +++ b/homeassistant/components/telegram_bot/notify.py @@ -0,0 +1,62 @@ +"""Telegram bot notification entity.""" + +from typing import Any + +import telegram + +from homeassistant.components.notify import NotifyEntity, NotifyEntityFeature +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import TelegramBotConfigEntry +from .const import ATTR_TITLE, CONF_CHAT_ID, DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TelegramBotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the telegram bot notification entity platform.""" + + for subentry_id, subentry in config_entry.subentries.items(): + async_add_entities( + [TelegramBotNotifyEntity(config_entry, subentry)], + config_subentry_id=subentry_id, + ) + + +class TelegramBotNotifyEntity(NotifyEntity): + """Representation of a telegram bot notification entity.""" + + _attr_supported_features = NotifyEntityFeature.TITLE + + def __init__( + self, + config_entry: TelegramBotConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize a notification entity.""" + bot_id = config_entry.runtime_data.bot.id + chat_id = subentry.data[CONF_CHAT_ID] + + self._attr_unique_id = f"{bot_id}_{chat_id}" + self.name = subentry.title + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer="Telegram", + model=config_entry.data[CONF_PLATFORM].capitalize(), + sw_version=telegram.__version__, + identifiers={(DOMAIN, f"{bot_id}")}, + ) + self._target = chat_id + self._service = config_entry.runtime_data + + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Send a message.""" + kwargs: dict[str, Any] = {ATTR_TITLE: title} + await self._service.send_message(message, self._target, self._context, **kwargs) diff --git a/tests/components/telegram_bot/test_notify.py b/tests/components/telegram_bot/test_notify.py new file mode 100644 index 00000000000..d43d5492760 --- /dev/null +++ b/tests/components/telegram_bot/test_notify.py @@ -0,0 +1,72 @@ +"""Test the telegram bot notify platform.""" + +from datetime import datetime +from unittest.mock import AsyncMock, patch + +from freezegun.api import freeze_time +from telegram import Chat, Message +from telegram.constants import ChatType, ParseMode + +from homeassistant.components.notify import ( + ATTR_MESSAGE, + ATTR_TITLE, + DOMAIN as NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import Context, HomeAssistant + +from tests.common import async_capture_events + + +@freeze_time("2025-01-09T12:00:00+00:00") +async def test_send_message( + hass: HomeAssistant, + webhook_platform: None, +) -> None: + """Test publishing ntfy message.""" + + context = Context() + events = async_capture_events(hass, "telegram_sent") + + with patch( + "homeassistant.components.telegram_bot.bot.Bot.send_message", + AsyncMock( + return_value=Message( + message_id=12345, + date=datetime.now(), + chat=Chat(id=123456, type=ChatType.PRIVATE), + ) + ), + ) as mock_send_message: + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: "notify.telegram_bot_123456_12345678", + ATTR_MESSAGE: "mock message", + ATTR_TITLE: "mock title", + }, + blocking=True, + context=context, + ) + await hass.async_block_till_done() + + mock_send_message.assert_called_once_with( + 12345678, + "mock title\nmock message", + parse_mode=ParseMode.MARKDOWN, + disable_web_page_preview=None, + disable_notification=False, + reply_to_message_id=None, + reply_markup=None, + read_timeout=None, + message_thread_id=None, + ) + + state = hass.states.get("notify.telegram_bot_123456_12345678") + assert state + assert state.state == "2025-01-09T12:00:00+00:00" + + assert len(events) == 1 + assert events[0].context == context From a3904ce60c2ca0db5f9de3b9a0bf0a5639e6ecde Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 12 Aug 2025 15:28:42 +0200 Subject: [PATCH 0949/1113] Sort Tuya DPCodes alphabetically (#150477) --- homeassistant/components/tuya/const.py | 28 +++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index b60b3bc518c..1ef18f4ea2b 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -101,11 +101,11 @@ class DPCode(StrEnum): AIR_QUALITY = "air_quality" AIR_QUALITY_INDEX = "air_quality_index" ALARM_DELAY_TIME = "alarm_delay_time" + ALARM_MESSAGE = "alarm_message" + ALARM_MSG = "alarm_msg" ALARM_SWITCH = "alarm_switch" # Alarm switch ALARM_TIME = "alarm_time" # Alarm time ALARM_VOLUME = "alarm_volume" # Alarm volume - ALARM_MESSAGE = "alarm_message" - ALARM_MSG = "alarm_msg" ANGLE_HORIZONTAL = "angle_horizontal" ANGLE_VERTICAL = "angle_vertical" ANION = "anion" # Ionizer unit @@ -147,8 +147,8 @@ class DPCode(StrEnum): CLEAN_AREA = "clean_area" CLEAN_TIME = "clean_time" CLICK_SUSTAIN_TIME = "click_sustain_time" - CLOUD_RECIPE_NUMBER = "cloud_recipe_number" CLOSED_OPENED_KIT = "closed_opened_kit" + CLOUD_RECIPE_NUMBER = "cloud_recipe_number" CO_STATE = "co_state" CO_STATUS = "co_status" CO_VALUE = "co_value" @@ -159,14 +159,14 @@ class DPCode(StrEnum): COLOUR_DATA = "colour_data" # Colored light mode COLOUR_DATA_HSV = "colour_data_hsv" # Colored light mode COLOUR_DATA_V2 = "colour_data_v2" # Colored light mode - COOK_TEMPERATURE = "cook_temperature" - COOK_TIME = "cook_time" CONCENTRATION_SET = "concentration_set" # Concentration setting CONTROL = "control" CONTROL_2 = "control_2" CONTROL_3 = "control_3" CONTROL_BACK = "control_back" CONTROL_BACK_MODE = "control_back_mode" + COOK_TEMPERATURE = "cook_temperature" + COOK_TIME = "cook_time" COUNTDOWN = "countdown" # Countdown COUNTDOWN_1 = "countdown_1" COUNTDOWN_2 = "countdown_2" @@ -202,11 +202,11 @@ class DPCode(StrEnum): FAN_COOL = "fan_cool" # Cool wind FAN_DIRECTION = "fan_direction" # Fan direction FAN_HORIZONTAL = "fan_horizontal" # Horizontal swing flap angle + FAN_MODE = "fan_mode" FAN_SPEED = "fan_speed" FAN_SPEED_ENUM = "fan_speed_enum" # Speed mode FAN_SPEED_PERCENT = "fan_speed_percent" # Stepless speed FAN_SWITCH = "fan_switch" - FAN_MODE = "fan_mode" FAN_VERTICAL = "fan_vertical" # Vertical swing flap angle FAR_DETECTION = "far_detection" FAULT = "fault" @@ -249,10 +249,10 @@ class DPCode(StrEnum): 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" + MASTER_MODE = "master_mode" # alarm mode + MASTER_STATE = "master_state" # alarm state MATERIAL = "material" # Material MAX_SET = "max_set" MINI_SET = "mini_set" @@ -266,6 +266,7 @@ class DPCode(StrEnum): MUFFLING = "muffling" # Muffling NEAR_DETECTION = "near_detection" OPPOSITE = "opposite" + OXYGEN = "oxygen" # Oxygen bar PAUSE = "pause" PERCENT_CONTROL = "percent_control" PERCENT_CONTROL_2 = "percent_control_2" @@ -273,7 +274,6 @@ class DPCode(StrEnum): PERCENT_STATE = "percent_state" PERCENT_STATE_2 = "percent_state_2" PERCENT_STATE_3 = "percent_state_3" - POSITION = "position" PHASE_A = "phase_a" PHASE_B = "phase_b" PHASE_C = "phase_c" @@ -283,20 +283,20 @@ class DPCode(StrEnum): PM25 = "pm25" PM25_STATE = "pm25_state" PM25_VALUE = "pm25_value" + POSITION = "position" POWDER_SET = "powder_set" # Powder POWER = "power" POWER_GO = "power_go" + POWER_TOTAL = "power_total" PREHEAT = "preheat" PREHEAT_1 = "preheat_1" PREHEAT_2 = "preheat_2" - POWER_TOTAL = "power_total" PRESENCE_STATE = "presence_state" PRESSURE_STATE = "pressure_state" PRESSURE_VALUE = "pressure_value" PUMP = "pump" PUMP_RESET = "pump_reset" # Water pump reset PUMP_TIME = "pump_time" # Water pump duration - OXYGEN = "oxygen" # Oxygen bar RAIN_24H = "rain_24h" # Total daily rainfall in mm RAIN_RATE = "rain_rate" # Rain intensity in mm/h RECORD_MODE = "record_mode" @@ -382,7 +382,6 @@ class DPCode(StrEnum): 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 = ( "temp_current_external" # Current external temperature in Celsius ) @@ -398,6 +397,7 @@ class DPCode(StrEnum): TEMP_CURRENT_EXTERNAL_F = ( "temp_current_external_f" # Current external temperature in Fahrenheit ) + TEMP_CURRENT_F = "temp_current_f" # Current temperature in °F TEMP_INDOOR = "temp_indoor" # Indoor temperature in °C TEMP_SET = "temp_set" # Set the temperature in °C TEMP_SET_F = "temp_set_f" # Set the temperature in °F @@ -411,9 +411,9 @@ class DPCode(StrEnum): TOTAL_CLEAN_COUNT = "total_clean_count" TOTAL_CLEAN_TIME = "total_clean_time" TOTAL_FORWARD_ENERGY = "total_forward_energy" - TOTAL_TIME = "total_time" TOTAL_PM = "total_pm" TOTAL_POWER = "total_power" + TOTAL_TIME = "total_time" TVOC = "tvoc" UPPER_TEMP = "upper_temp" UPPER_TEMP_F = "upper_temp_f" @@ -432,10 +432,10 @@ class DPCode(StrEnum): WARM = "warm" # Heat preservation WARM_TIME = "warm_time" # Heat preservation time WATER = "water" + WATER_LEVEL = "water_level" WATER_RESET = "water_reset" # Resetting of water usage days WATER_SET = "water_set" # Water level WATER_TIME = "water_time" # Water usage duration - WATER_LEVEL = "water_level" WATERSENSOR_STATE = "watersensor_state" WEATHER_DELAY = "weather_delay" WET = "wet" # Humidification From 711afa306c37f2d5c29adfc7e762d647320dca1e Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Tue, 12 Aug 2025 15:39:28 +0200 Subject: [PATCH 0950/1113] Add `number` platform for LED brightness to air-Q (#150492) --- homeassistant/components/airq/__init__.py | 2 +- homeassistant/components/airq/coordinator.py | 1 + homeassistant/components/airq/number.py | 85 ++++++++++++++++++++ homeassistant/components/airq/strings.json | 5 ++ tests/components/airq/__init__.py | 31 +++++++ tests/components/airq/common.py | 1 + tests/components/airq/conftest.py | 3 +- tests/components/airq/test_number.py | 70 ++++++++++++++++ 8 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/airq/number.py create mode 100644 tests/components/airq/test_number.py diff --git a/homeassistant/components/airq/__init__.py b/homeassistant/components/airq/__init__.py index ab64915c8ae..f87365797e7 100644 --- a/homeassistant/components/airq/__init__.py +++ b/homeassistant/components/airq/__init__.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from .const import CONF_CLIP_NEGATIVE, CONF_RETURN_AVERAGE from .coordinator import AirQCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR] AirQConfigEntry = ConfigEntry[AirQCoordinator] diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py index 3ab41978b05..7c62a023a11 100644 --- a/homeassistant/components/airq/coordinator.py +++ b/homeassistant/components/airq/coordinator.py @@ -75,6 +75,7 @@ class AirQCoordinator(DataUpdateCoordinator): return_average=self.return_average, clip_negative_values=self.clip_negative, ) + data["brightness"] = await self.airq.get_current_brightness() if warming_up_sensors := identify_warming_up_sensors(data): _LOGGER.debug( "Following sensors are still warming up: %s", warming_up_sensors diff --git a/homeassistant/components/airq/number.py b/homeassistant/components/airq/number.py new file mode 100644 index 00000000000..e980760ed52 --- /dev/null +++ b/homeassistant/components/airq/number.py @@ -0,0 +1,85 @@ +"""Definition of air-Q number platform used to control the LED strips.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +import logging + +from aioairq.core import AirQ + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import AirQConfigEntry, AirQCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class AirQBrightnessDescription(NumberEntityDescription): + """Describes AirQ number entity responsible for brightness control.""" + + value: Callable[[dict], float] + set_value: Callable[[AirQ, float], Awaitable[None]] + + +AIRQ_LED_BRIGHTNESS = AirQBrightnessDescription( + key="airq_led_brightness", + translation_key="airq_led_brightness", + native_min_value=0.0, + native_max_value=100.0, + native_step=1.0, + native_unit_of_measurement=PERCENTAGE, + value=lambda data: data["brightness"], + set_value=lambda device, value: device.set_current_brightness(value), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AirQConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up number entities: a single entity for the LEDs.""" + + coordinator = entry.runtime_data + entities = [AirQLEDBrightness(coordinator, AIRQ_LED_BRIGHTNESS)] + + async_add_entities(entities) + + +class AirQLEDBrightness(CoordinatorEntity[AirQCoordinator], NumberEntity): + """Representation of the LEDs from a single AirQ.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AirQCoordinator, + description: AirQBrightnessDescription, + ) -> None: + """Initialize a single sensor.""" + super().__init__(coordinator) + self.entity_description: AirQBrightnessDescription = description + + self._attr_device_info = coordinator.device_info + self._attr_unique_id = f"{coordinator.device_id}_{description.key}" + + @property + def native_value(self) -> float: + """Return the brightness of the LEDs in %.""" + return self.entity_description.value(self.coordinator.data) + + async def async_set_native_value(self, value: float) -> None: + """Set the brightness of the LEDs to the value in %.""" + _LOGGER.debug( + "Changing LED brighntess from %.0f%% to %.0f%%", + self.coordinator.data["brightness"], + value, + ) + await self.entity_description.set_value(self.coordinator.airq, value) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/airq/strings.json b/homeassistant/components/airq/strings.json index de8c7d86b09..2972ba5c15b 100644 --- a/homeassistant/components/airq/strings.json +++ b/homeassistant/components/airq/strings.json @@ -35,6 +35,11 @@ } }, "entity": { + "number": { + "airq_led_brightness": { + "name": "LED brightness" + } + }, "sensor": { "acetaldehyde": { "name": "Acetaldehyde" diff --git a/tests/components/airq/__init__.py b/tests/components/airq/__init__.py index 612761c0653..41bc1e467dc 100644 --- a/tests/components/airq/__init__.py +++ b/tests/components/airq/__init__.py @@ -1 +1,32 @@ """Tests for the air-Q integration.""" + +from unittest.mock import patch + +from homeassistant.components.airq.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .common import TEST_DEVICE_INFO, TEST_USER_DATA + +from tests.common import MockConfigEntry + + +async def setup_platform(hass: HomeAssistant, platform: Platform) -> None: + """Load AirQ integration. + + This function does not patch AirQ itself, rather it depends on being + run in presence of `mock_coordinator_airq` fixture, which patches calls + by `AirQCoordinator.airq`, which are done under `async_setup`. + + Patching airq.PLATFORMS allows to set up a single platform in isolation. + """ + config_entry = MockConfigEntry( + domain=DOMAIN, data=TEST_USER_DATA, unique_id=TEST_DEVICE_INFO["id"] + ) + config_entry.add_to_hass(hass) + + # The patching is now handled by the mock_airq fixture. + # We just need to load the component. + with patch("homeassistant.components.airq.PLATFORMS", [platform]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/airq/common.py b/tests/components/airq/common.py index 275da60e3a2..2e568c3c3cb 100644 --- a/tests/components/airq/common.py +++ b/tests/components/airq/common.py @@ -16,3 +16,4 @@ TEST_DEVICE_INFO = DeviceInfo( hw_version="hw", ) TEST_DEVICE_DATA = {"co2": 500.0, "Status": "OK"} +TEST_BRIGHTNESS = 42 diff --git a/tests/components/airq/conftest.py b/tests/components/airq/conftest.py index 52d7fc77eb4..21118c3ef27 100644 --- a/tests/components/airq/conftest.py +++ b/tests/components/airq/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest -from .common import TEST_DEVICE_DATA, TEST_DEVICE_INFO +from .common import TEST_BRIGHTNESS, TEST_DEVICE_DATA, TEST_DEVICE_INFO @pytest.fixture @@ -38,4 +38,5 @@ def mock_airq(): # 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) + airq.get_current_brightness = AsyncMock(return_value=TEST_BRIGHTNESS) yield airq diff --git a/tests/components/airq/test_number.py b/tests/components/airq/test_number.py new file mode 100644 index 00000000000..b5fa4d65ef1 --- /dev/null +++ b/tests/components/airq/test_number.py @@ -0,0 +1,70 @@ +"""Test the NUMBER platform from air-Q integration.""" + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from . import setup_platform +from .common import TEST_BRIGHTNESS, TEST_DEVICE_INFO + +ENTITY_ID = f"number.{TEST_DEVICE_INFO['name']}_led_brightness" + + +@pytest.fixture(autouse=True) +async def number_platform(hass: HomeAssistant, mock_airq: AsyncMock) -> None: + """Configure AirQ integration and validate the setup for NUMBER platform.""" + await setup_platform(hass, Platform.NUMBER) + + # Validate the setup + state = hass.states.get(ENTITY_ID) + assert state is not None, ( + f"{ENTITY_ID} not found among {hass.states.async_entity_ids()}" + ) + assert float(state.state) == TEST_BRIGHTNESS + + +@pytest.mark.parametrize("new_brightness", [0, 100, (TEST_BRIGHTNESS + 10) % 100]) +async def test_number_set_value( + hass: HomeAssistant, mock_airq: AsyncMock, new_brightness +) -> None: + """Test that setting value works.""" + # Simulate the device confirming the new brightness on the next poll + mock_airq.get_current_brightness.return_value = new_brightness + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": ENTITY_ID, "value": new_brightness}, + blocking=True, + ) + await hass.async_block_till_done() + + # Verify the API methods were called correctly + mock_airq.set_current_brightness.assert_called_once_with(new_brightness) + + # Validate that the update propagated to the state + state = hass.states.get(ENTITY_ID) + assert state is not None, ( + f"{ENTITY_ID} not found among {hass.states.async_entity_ids()}" + ) + assert float(state.state) == new_brightness + + +@pytest.mark.parametrize("new_brightness", [-1, 110]) +async def test_number_set_invalid_value_caught_by_hass( + hass: HomeAssistant, mock_airq: AsyncMock, new_brightness +) -> None: + """Test that setting incorrect values errors.""" + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + "number", + "set_value", + {"entity_id": ENTITY_ID, "value": new_brightness}, + blocking=True, + ) + + mock_airq.set_current_brightness.assert_not_called() From 07930b12d0d2115ada569e285f7f2cf9a65b304f Mon Sep 17 00:00:00 2001 From: wedsa5 Date: Tue, 12 Aug 2025 08:36:52 -0600 Subject: [PATCH 0951/1113] Fix brightness command not sent when in white color mode (#150439) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/tuya/light.py | 7 +++- tests/components/tuya/test_light.py | 55 +++++++++++++++++++++++--- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 9848351047c..673e9b1ffb3 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -663,8 +663,11 @@ class TuyaLightEntity(TuyaEntity, LightEntity): }, ] - elif ATTR_BRIGHTNESS in kwargs and self._brightness: - brightness = kwargs[ATTR_BRIGHTNESS] + elif self._brightness and (ATTR_BRIGHTNESS in kwargs or ATTR_WHITE in kwargs): + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] + else: + brightness = kwargs[ATTR_WHITE] # If there is a min/max value, the brightness is actually limited. # Meaning it is actually not on a 0-255 scale. diff --git a/tests/components/tuya/test_light.py b/tests/components/tuya/test_light.py index 008d918cee1..6c36f9ef838 100644 --- a/tests/components/tuya/test_light.py +++ b/tests/components/tuya/test_light.py @@ -2,6 +2,7 @@ from __future__ import annotations +from typing import Any from unittest.mock import patch import pytest @@ -42,11 +43,58 @@ async def test_platform_setup_and_discovery( "mock_device_code", ["dj_mki13ie507rlry4r"], ) +@pytest.mark.parametrize( + ("turn_on_input", "expected_commands"), + [ + ( + { + "white": True, + }, + [ + {"code": "switch_led", "value": True}, + {"code": "work_mode", "value": "white"}, + {"code": "bright_value_v2", "value": 546}, + ], + ), + ( + { + "brightness": 150, + }, + [ + {"code": "switch_led", "value": True}, + {"code": "bright_value_v2", "value": 592}, + ], + ), + ( + { + "white": True, + "brightness": 150, + }, + [ + {"code": "switch_led", "value": True}, + {"code": "work_mode", "value": "white"}, + {"code": "bright_value_v2", "value": 592}, + ], + ), + ( + { + "white": 150, + }, + [ + {"code": "switch_led", "value": True}, + {"code": "work_mode", "value": "white"}, + {"code": "bright_value_v2", "value": 592}, + ], + ), + ], +) async def test_turn_on_white( hass: HomeAssistant, mock_manager: ManagerCompat, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, + turn_on_input: dict[str, Any], + expected_commands: list[dict[str, Any]], ) -> None: """Test turn_on service.""" entity_id = "light.garage_light" @@ -59,16 +107,13 @@ async def test_turn_on_white( SERVICE_TURN_ON, { "entity_id": entity_id, - "white": 150, + **turn_on_input, }, ) 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"}, - ], + expected_commands, ) From 89aa349881e2f406daee627a0014796e5dbf2aea Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 12 Aug 2025 17:18:27 +0200 Subject: [PATCH 0952/1113] Fix spelling of "an HS color command" in `template` (#150495) --- 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 200b323d377..dece4580098 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -216,7 +216,7 @@ "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`.", + "set_hs": "Defines actions to run when the light is given an 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`." }, From 98e6e20079d96cebeb75089a54f631f166bfd15b Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 12 Aug 2025 17:46:31 +0200 Subject: [PATCH 0953/1113] Mock habluetooth adapters (#148919) --- tests/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/conftest.py b/tests/conftest.py index 9fdf010eb64..acb50b0029c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1817,6 +1817,7 @@ async def mock_enable_bluetooth( def mock_bluetooth_adapters() -> Generator[None]: """Fixture to mock bluetooth adapters.""" with ( + patch("habluetooth.util.recover_adapter"), patch("bluetooth_auto_recovery.recover_adapter"), patch("bluetooth_adapters.systems.platform.system", return_value="Linux"), patch("bluetooth_adapters.systems.linux.LinuxAdapters.refresh"), From 218b0738ca067aba41749855ba96e3a1a6a4981f Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 12 Aug 2025 18:00:51 +0200 Subject: [PATCH 0954/1113] Modbus: Remove wrong comment on non-existing parameter. (#150501) --- homeassistant/components/modbus/climate.py | 3 --- homeassistant/components/modbus/cover.py | 2 -- homeassistant/components/modbus/entity.py | 2 -- homeassistant/components/modbus/sensor.py | 2 -- 4 files changed, 9 deletions(-) diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 1138734b5bf..b3d6c78387d 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -467,9 +467,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): async def _async_update(self) -> None: """Update Target & Current Temperature.""" - # remark "now" is a dummy parameter to avoid problems with - # async_track_time_interval - self._attr_target_temperature = await self._async_read_register( CALL_TYPE_REGISTER_HOLDING, self._target_temperature_register[ diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 5e7b008ff7c..23a09431072 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -123,8 +123,6 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): async def _async_update(self) -> None: """Update the state of the cover.""" - # remark "now" is a dummy parameter to avoid problems with - # async_track_time_interval result = await self._hub.async_pb_call( self._slave, self._address, 1, self._input_type ) diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py index 10ce211fc25..eaf13d5bca4 100644 --- a/homeassistant/components/modbus/entity.py +++ b/homeassistant/components/modbus/entity.py @@ -380,8 +380,6 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): async def _async_update(self) -> None: """Update the entity state.""" - # remark "now" is a dummy parameter to avoid problems with - # async_track_time_interval if not self._verify_active: self._attr_available = True return diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 767fed5fceb..a11e25b4dd4 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -106,8 +106,6 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): async def _async_update(self) -> None: """Update the state of the sensor.""" - # remark "now" is a dummy parameter to avoid problems with - # async_track_time_interval self._cancel_call = None raw_result = await self._hub.async_pb_call( self._slave, self._address, self._count, self._input_type From ad3174f6e689b93e93d96a1d85b19faa3b4f317f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 12 Aug 2025 18:02:26 +0200 Subject: [PATCH 0955/1113] Rename Tuya parsing models (#150498) --- homeassistant/components/tuya/models.py | 16 ++++----- homeassistant/components/tuya/sensor.py | 44 ++++++++++++------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/tuya/models.py b/homeassistant/components/tuya/models.py index 059889b754f..82cf5ebd200 100644 --- a/homeassistant/components/tuya/models.py +++ b/homeassistant/components/tuya/models.py @@ -99,23 +99,23 @@ class EnumTypeData: return cls(dpcode, **parsed) -class ComplexTypeData: - """Complex Type Data (for JSON/RAW parsing).""" +class ComplexValue: + """Complex value (for JSON/RAW parsing).""" @classmethod def from_json(cls, data: str) -> Self: - """Load JSON string and return a ComplexTypeData object.""" + """Load JSON string and return a ComplexValue object.""" raise NotImplementedError("from_json is not implemented for this type") @classmethod def from_raw(cls, data: str) -> Self | None: - """Decode base64 string and return a ComplexTypeData object.""" + """Decode base64 string and return a ComplexValue object.""" raise NotImplementedError("from_raw is not implemented for this type") @dataclass -class ElectricityTypeData(ComplexTypeData): - """Electricity Type Data.""" +class ElectricityValue(ComplexValue): + """Electricity complex value.""" electriccurrent: str | None = None power: str | None = None @@ -123,12 +123,12 @@ class ElectricityTypeData(ComplexTypeData): @classmethod def from_json(cls, data: str) -> Self: - """Load JSON string and return a ElectricityTypeData object.""" + """Load JSON string and return a ElectricityValue object.""" return cls(**json.loads(data.lower())) @classmethod def from_raw(cls, data: str) -> Self | None: - """Decode base64 string and return a ElectricityTypeData object.""" + """Decode base64 string and return a ElectricityValue object.""" raw = base64.b64decode(data) if len(raw) == 0: return None diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 275b7c28e3a..48543f2cd48 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -42,7 +42,7 @@ from .const import ( UnitOfMeasurement, ) from .entity import TuyaEntity -from .models import ComplexTypeData, ElectricityTypeData, EnumTypeData, IntegerTypeData +from .models import ComplexValue, ElectricityValue, EnumTypeData, IntegerTypeData _WIND_DIRECTIONS = { "north": 0.0, @@ -68,7 +68,7 @@ _WIND_DIRECTIONS = { class TuyaSensorEntityDescription(SensorEntityDescription): """Describes Tuya sensor entity.""" - complex_type: type[ComplexTypeData] | None = None + complex_type: type[ComplexValue] | None = None subkey: str | None = None state_conversion: Callable[[Any], StateType] | None = None @@ -398,7 +398,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, - complex_type=ElectricityTypeData, + complex_type=ElectricityValue, subkey="electriccurrent", ), TuyaSensorEntityDescription( @@ -407,7 +407,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, - complex_type=ElectricityTypeData, + complex_type=ElectricityValue, subkey="power", ), TuyaSensorEntityDescription( @@ -416,7 +416,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, - complex_type=ElectricityTypeData, + complex_type=ElectricityValue, subkey="voltage", ), TuyaSensorEntityDescription( @@ -425,7 +425,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, - complex_type=ElectricityTypeData, + complex_type=ElectricityValue, subkey="electriccurrent", ), TuyaSensorEntityDescription( @@ -434,7 +434,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, - complex_type=ElectricityTypeData, + complex_type=ElectricityValue, subkey="power", ), TuyaSensorEntityDescription( @@ -443,7 +443,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, - complex_type=ElectricityTypeData, + complex_type=ElectricityValue, subkey="voltage", ), TuyaSensorEntityDescription( @@ -452,7 +452,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, - complex_type=ElectricityTypeData, + complex_type=ElectricityValue, subkey="electriccurrent", ), TuyaSensorEntityDescription( @@ -461,7 +461,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, - complex_type=ElectricityTypeData, + complex_type=ElectricityValue, subkey="power", ), TuyaSensorEntityDescription( @@ -470,7 +470,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, - complex_type=ElectricityTypeData, + complex_type=ElectricityValue, subkey="voltage", ), TuyaSensorEntityDescription( @@ -1396,7 +1396,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="total_power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - complex_type=ElectricityTypeData, + complex_type=ElectricityValue, subkey="power", ), TuyaSensorEntityDescription( @@ -1412,7 +1412,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, - complex_type=ElectricityTypeData, + complex_type=ElectricityValue, subkey="electriccurrent", ), TuyaSensorEntityDescription( @@ -1421,7 +1421,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, - complex_type=ElectricityTypeData, + complex_type=ElectricityValue, subkey="power", ), TuyaSensorEntityDescription( @@ -1430,7 +1430,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, - complex_type=ElectricityTypeData, + complex_type=ElectricityValue, subkey="voltage", ), TuyaSensorEntityDescription( @@ -1439,7 +1439,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, - complex_type=ElectricityTypeData, + complex_type=ElectricityValue, subkey="electriccurrent", ), TuyaSensorEntityDescription( @@ -1448,7 +1448,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, - complex_type=ElectricityTypeData, + complex_type=ElectricityValue, subkey="power", ), TuyaSensorEntityDescription( @@ -1457,7 +1457,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, - complex_type=ElectricityTypeData, + complex_type=ElectricityValue, subkey="voltage", ), TuyaSensorEntityDescription( @@ -1466,7 +1466,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, - complex_type=ElectricityTypeData, + complex_type=ElectricityValue, subkey="electriccurrent", ), TuyaSensorEntityDescription( @@ -1475,7 +1475,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, - complex_type=ElectricityTypeData, + complex_type=ElectricityValue, subkey="power", ), TuyaSensorEntityDescription( @@ -1484,7 +1484,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, - complex_type=ElectricityTypeData, + complex_type=ElectricityValue, subkey="voltage", ), ), @@ -1583,7 +1583,7 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): _status_range: DeviceStatusRange | None = None _type: DPType | None = None - _type_data: IntegerTypeData | EnumTypeData | ComplexTypeData | None = None + _type_data: IntegerTypeData | EnumTypeData | None = None _uom: UnitOfMeasurement | None = None def __init__( From ca290ee6313209d0f0be89a6a053c6fe54873631 Mon Sep 17 00:00:00 2001 From: Tucker Kern Date: Tue, 12 Aug 2025 10:07:29 -0600 Subject: [PATCH 0956/1113] Implement Snapcast grouping with standard HA actions (#146855) Co-authored-by: Joost Lekkerkerker --- .../components/snapcast/coordinator.py | 7 + .../components/snapcast/media_player.py | 239 +++++++++++++----- .../components/snapcast/strings.json | 10 + tests/components/snapcast/conftest.py | 89 +++++-- .../snapcast/snapshots/test_media_player.ambr | 184 +++++++------- .../components/snapcast/test_media_player.py | 201 ++++++++++++++- 6 files changed, 546 insertions(+), 184 deletions(-) diff --git a/homeassistant/components/snapcast/coordinator.py b/homeassistant/components/snapcast/coordinator.py index 4c2f0cb81b7..963f12887fc 100644 --- a/homeassistant/components/snapcast/coordinator.py +++ b/homeassistant/components/snapcast/coordinator.py @@ -39,6 +39,8 @@ class SnapcastUpdateCoordinator(DataUpdateCoordinator[None]): self._server.set_on_connect_callback(self._on_connect) self._server.set_on_disconnect_callback(self._on_disconnect) + self._host_id = f"{host}:{port}" + def _on_update(self) -> None: """Snapserver on_update callback.""" # Assume availability if an update is received. @@ -77,3 +79,8 @@ class SnapcastUpdateCoordinator(DataUpdateCoordinator[None]): def server(self) -> Snapserver: """Get the Snapserver object.""" return self._server + + @property + def host_id(self) -> str: + """Get the host ID.""" + return self._host_id diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 8e3f787e71d..ccb9d4c4c46 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping import logging from typing import Any @@ -19,13 +19,13 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import ( config_validation as cv, entity_platform, entity_registry as er, + issue_registry as ir, ) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -52,6 +52,12 @@ STREAM_STATUS = { "unknown": None, } +_SUPPORTED_FEATURES = ( + MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.SELECT_SOURCE +) + _LOGGER = logging.getLogger(__name__) @@ -82,106 +88,91 @@ async def async_setup_entry( # Fetch coordinator from global data coordinator: SnapcastUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - # Create an ID for the Snapserver - host = config_entry.data[CONF_HOST] - port = config_entry.data[CONF_PORT] - host_id = f"{host}:{port}" - register_services() _known_group_ids: set[str] = set() _known_client_ids: set[str] = set() @callback - def _check_entities() -> None: - nonlocal _known_group_ids, _known_client_ids + def _update_entities( + entity_class: type[SnapcastClientDevice | SnapcastGroupDevice], + known_ids: set[str], + get_device: Callable[[str], Snapclient | Snapgroup], + get_devices: Callable[[], list[Snapclient] | list[Snapgroup]], + ) -> None: + # Get IDs of current devices on server + snapcast_ids = {d.identifier for d in get_devices()} - def _update_known_ids(known_ids, ids) -> tuple[set[str], set[str]]: - ids_to_add = ids - known_ids - ids_to_remove = known_ids - ids + # Update known IDs + ids_to_add = snapcast_ids - known_ids + ids_to_remove = known_ids - snapcast_ids - # Update known IDs - known_ids.difference_update(ids_to_remove) - known_ids.update(ids_to_add) - - return ids_to_add, ids_to_remove - - group_ids = {g.identifier for g in coordinator.server.groups} - groups_to_add, groups_to_remove = _update_known_ids(_known_group_ids, group_ids) - - client_ids = {c.identifier for c in coordinator.server.clients} - clients_to_add, clients_to_remove = _update_known_ids( - _known_client_ids, client_ids - ) + known_ids.difference_update(ids_to_remove) + known_ids.update(ids_to_add) # Exit early if no changes - if not (groups_to_add | groups_to_remove | clients_to_add | clients_to_remove): + if not (ids_to_add | ids_to_remove): return _LOGGER.debug( - "New clients: %s", - str([coordinator.server.client(c).friendly_name for c in clients_to_add]), + "New %s: %s", + entity_class, + str([get_device(d).friendly_name for d in ids_to_add]), ) _LOGGER.debug( - "New groups: %s", - str([coordinator.server.group(g).friendly_name for g in groups_to_add]), - ) - _LOGGER.debug( - "Remove client IDs: %s", - str([list(clients_to_remove)]), - ) - _LOGGER.debug( - "Remove group IDs: %s", - str(list(groups_to_remove)), + "Remove %s IDs: %s", + entity_class, + str([list(ids_to_remove)]), ) # Add new entities async_add_entities( [ - SnapcastGroupDevice( - coordinator, coordinator.server.group(group_id), host_id - ) - for group_id in groups_to_add - ] - + [ - SnapcastClientDevice( - coordinator, coordinator.server.client(client_id), host_id - ) - for client_id in clients_to_add + entity_class(coordinator, get_device(snapcast_id)) + for snapcast_id in ids_to_add ] ) # Remove stale entities entity_registry = er.async_get(hass) - for group_id in groups_to_remove: + for snapcast_id in ids_to_remove: if entity_id := entity_registry.async_get_entity_id( MEDIA_PLAYER_DOMAIN, DOMAIN, - SnapcastGroupDevice.get_unique_id(host_id, group_id), + entity_class.get_unique_id(coordinator.host_id, snapcast_id), ): entity_registry.async_remove(entity_id) - for client_id in clients_to_remove: - if entity_id := entity_registry.async_get_entity_id( - MEDIA_PLAYER_DOMAIN, - DOMAIN, - SnapcastClientDevice.get_unique_id(host_id, client_id), - ): - entity_registry.async_remove(entity_id) + def _update_clients() -> None: + _update_entities( + SnapcastClientDevice, + _known_client_ids, + coordinator.server.client, + lambda: coordinator.server.clients, + ) - coordinator.async_add_listener(_check_entities) - _check_entities() + # Create client entities and add listener to update clients on server update + _update_clients() + coordinator.async_add_listener(_update_clients) + + def _update_groups() -> None: + _update_entities( + SnapcastGroupDevice, + _known_group_ids, + coordinator.server.group, + lambda: coordinator.server.groups, + ) + + # Create group entities and add listener to update groups on server update + _update_groups() + coordinator.async_add_listener(_update_groups) class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity): """Base class representing a Snapcast device.""" _attr_should_poll = False - _attr_supported_features = ( - MediaPlayerEntityFeature.VOLUME_MUTE - | MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.SELECT_SOURCE - ) + _attr_supported_features = _SUPPORTED_FEATURES _attr_media_content_type = MediaType.MUSIC _attr_device_class = MediaPlayerDeviceClass.SPEAKER @@ -189,13 +180,14 @@ class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity): self, coordinator: SnapcastUpdateCoordinator, device: Snapgroup | Snapclient, - host_id: str, ) -> None: """Initialize the base device.""" super().__init__(coordinator) self._device = device - self._attr_unique_id = self.get_unique_id(host_id, device.identifier) + self._attr_unique_id = self.get_unique_id( + coordinator.host_id, device.identifier + ) @classmethod def get_unique_id(cls, host, id) -> str: @@ -279,6 +271,19 @@ class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity): """Handle the unjoin service.""" raise NotImplementedError + def _async_create_grouping_deprecation_issue(self) -> None: + """Create an issue for deprecated grouping actions.""" + ir.async_create_issue( + self.hass, + DOMAIN, + "deprecated_grouping_actions", + breaks_in_ha_version="2026.2.0", + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_grouping_actions", + ) + @property def metadata(self) -> Mapping[str, Any]: """Get metadata from the current stream.""" @@ -389,11 +394,62 @@ class SnapcastGroupDevice(SnapcastBaseDevice): """Handle the unjoin service.""" raise ServiceValidationError("Entity is not a client. Can only unjoin clients.") + def _async_create_group_deprecation_issue(self) -> None: + """Create an issue for deprecated group entities.""" + ir.async_create_issue( + self.hass, + DOMAIN, + "deprecated_group_entities", + breaks_in_ha_version="2026.2.0", + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_group_entities", + ) + + async def async_select_source(self, source: str) -> None: + """Set input source.""" + # Groups are deprecated, create an issue when used + self._async_create_group_deprecation_issue() + + await super().async_select_source(source) + + async def async_mute_volume(self, mute: bool) -> None: + """Send the mute command.""" + # Groups are deprecated, create an issue when used + self._async_create_group_deprecation_issue() + + await super().async_mute_volume(mute) + + async def async_set_volume_level(self, volume: float) -> None: + """Set the volume level.""" + # Groups are deprecated, create an issue when used + self._async_create_group_deprecation_issue() + + await super().async_set_volume_level(volume) + + def snapshot(self) -> None: + """Snapshot the group state.""" + # Groups are deprecated, create an issue when used + self._async_create_group_deprecation_issue() + + super().snapshot() + + async def async_restore(self) -> None: + """Restore the group state.""" + # Groups are deprecated, create an issue when used + self._async_create_group_deprecation_issue() + + await super().async_restore() + class SnapcastClientDevice(SnapcastBaseDevice): """Representation of a Snapcast client device.""" _device: Snapclient + _attr_supported_features = ( + _SUPPORTED_FEATURES | MediaPlayerEntityFeature.GROUPING + ) # Clients support grouping @classmethod def get_unique_id(cls, host, id) -> str: @@ -439,6 +495,9 @@ class SnapcastClientDevice(SnapcastBaseDevice): async def async_join(self, master) -> None: """Join the group of the master player.""" + # Action is deprecated, create an issue + self._async_create_grouping_deprecation_issue() + entity_registry = er.async_get(self.hass) master_entity = entity_registry.async_get(master) if master_entity is None: @@ -463,5 +522,53 @@ class SnapcastClientDevice(SnapcastBaseDevice): async def async_unjoin(self) -> None: """Unjoin the group the player is currently in.""" + # Action is deprecated, create an issue + self._async_create_grouping_deprecation_issue() + + await self._current_group.remove_client(self._device.identifier) + self.async_write_ha_state() + + @property + def group_members(self) -> list[str] | None: + """List of player entities which are currently grouped together for synchronous playback.""" + entity_registry = er.async_get(self.hass) + return [ + entity_id + for client_id in self._current_group.clients + if ( + entity_id := entity_registry.async_get_entity_id( + MEDIA_PLAYER_DOMAIN, + DOMAIN, + self.get_unique_id(self.coordinator.host_id, client_id), + ) + ) + ] + + async def async_join_players(self, group_members: list[str]) -> None: + """Add `group_members` to this client's current group.""" + # Get the client entity for each group member excluding self + entity_registry = er.async_get(self.hass) + clients = [ + entity + for entity_id in group_members + if (entity := entity_registry.async_get(entity_id)) + and entity.unique_id != self.unique_id + ] + + for client in clients: + # Valid entity is a snapcast client + if not client.unique_id.startswith(CLIENT_PREFIX): + raise ServiceValidationError( + f"Entity '{client.entity_id}' is not a Snapcast client device." + ) + + # Extract client ID and join it to the current group + identifier = client.unique_id.split("_")[-1] + await self._current_group.add_client(identifier) + + self.async_write_ha_state() + + async def async_unjoin_player(self) -> None: + """Remove this client from it's current group.""" await self._current_group.remove_client(self._device.identifier) self.async_write_ha_state() diff --git a/homeassistant/components/snapcast/strings.json b/homeassistant/components/snapcast/strings.json index 685b4a0dd11..9336b1fac86 100644 --- a/homeassistant/components/snapcast/strings.json +++ b/homeassistant/components/snapcast/strings.json @@ -58,5 +58,15 @@ } } } + }, + "issues": { + "deprecated_grouping_actions": { + "title": "Snapcast Actions Deprecated", + "description": "Actions 'snapcast.join' and 'snapcast.unjoin' are deprecated and will be removed in 2026.2. Use the 'media_player.join' and 'media_player.unjoin' actions instead." + }, + "deprecated_group_entities": { + "title": "Snapcast Groups Entities Deprecated", + "description": "Snapcast group entities are deprecated and will be removed in 2026.2. Please use the 'media_player.join' and 'media_player.unjoin' actions instead." + } } } diff --git a/tests/components/snapcast/conftest.py b/tests/components/snapcast/conftest.py index c2c4ffa7997..282429b110a 100644 --- a/tests/components/snapcast/conftest.py +++ b/tests/components/snapcast/conftest.py @@ -36,8 +36,10 @@ def mock_server(mock_create_server: AsyncMock) -> Generator[AsyncMock]: @pytest.fixture def mock_create_server( - mock_group: AsyncMock, - mock_client: AsyncMock, + mock_group_1: AsyncMock, + mock_group_2: AsyncMock, + mock_client_1: AsyncMock, + mock_client_2: AsyncMock, mock_stream_1: AsyncMock, mock_stream_2: AsyncMock, ) -> Generator[AsyncMock]: @@ -46,16 +48,26 @@ def mock_create_server( "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.groups = [mock_group_1, mock_group_2] + mock_server.clients = [mock_client_1, mock_client_2] 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] + def get_group(identifier: str) -> AsyncMock: + return {s.identifier: s for s in mock_server.groups}[identifier] + + def get_client(identifier: str) -> AsyncMock: + return {s.identifier: s for s in mock_server.clients}[identifier] + mock_server.stream = get_stream + mock_server.group = get_group + mock_server.client = get_client + + mock_client_1.groups_available = lambda: mock_server.groups + mock_client_2.groups_available = lambda: mock_server.groups + yield mock_server @@ -74,34 +86,66 @@ async def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_group(stream: str, streams: dict[str, AsyncMock]) -> AsyncMock: +def mock_group_1(mock_stream_1: AsyncMock, 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.name = "test_group_1" + group.friendly_name = "Test Group 1" + group.stream = mock_stream_1.identifier group.muted = False - group.stream_status = streams[stream].status + group.stream_status = mock_stream_1.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: +def mock_group_2(mock_stream_2: AsyncMock, streams: dict[str, AsyncMock]) -> AsyncMock: + """Create a mock Snapgroup.""" + group = AsyncMock(spec=Snapgroup) + group.identifier = "4dcc4e3b-c699-a04b-7f0c-8260d23c43e2" + group.name = "test_group_2" + group.friendly_name = "Test Group 2" + group.stream = mock_stream_2.identifier + group.muted = False + group.stream_status = mock_stream_2.status + group.volume = 65 + group.streams_by_name.return_value = {s.friendly_name: s for s in streams.values()} + return group + + +@pytest.fixture +def mock_client_1(mock_group_1: 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.identifier = "00:21:6a:7d:74:fc#1" + client.friendly_name = "test_client_1" client.version = "0.10.0" client.connected = True - client.name = "Snapclient" + client.name = "Snapclient 1" client.latency = 6 client.muted = False client.volume = 48 - client.group = mock_group - mock_group.clients = [client.identifier] + client.group = mock_group_1 + mock_group_1.clients = [client.identifier] + return client + + +@pytest.fixture +def mock_client_2(mock_group_2: AsyncMock) -> AsyncMock: + """Create a mock Snapclient.""" + client = AsyncMock(spec=Snapclient) + client.identifier = "00:21:6a:7d:74:fc#2" + client.friendly_name = "test_client_2" + client.version = "0.10.0" + client.connected = True + client.name = "Snapclient 2" + client.latency = 6 + client.muted = False + client.volume = 100 + client.group = mock_group_2 + mock_group_2.clients = [client.identifier] return client @@ -149,17 +193,6 @@ def mock_stream_2() -> AsyncMock: 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.""" diff --git a/tests/components/snapcast/snapshots/test_media_player.ambr b/tests/components/snapcast/snapshots/test_media_player.ambr index c497cdd861b..3e408a0f14e 100644 --- a/tests/components/snapcast/snapshots/test_media_player.ambr +++ b/tests/components/snapcast/snapshots/test_media_player.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_state[test_stream_1][media_player.test_client_snapcast_client-entry] +# name: test_state[media_player.test_client_1_snapcast_client-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17,7 +17,7 @@ 'disabled_by': None, 'domain': 'media_player', 'entity_category': None, - 'entity_id': 'media_player.test_client_snapcast_client', + 'entity_id': 'media_player.test_client_1_snapcast_client', 'has_entity_name': False, 'hidden_by': None, 'icon': None, @@ -29,22 +29,25 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'test_client Snapcast Client', + 'original_name': 'test_client_1 Snapcast Client', 'platform': 'snapcast', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, - 'unique_id': 'snapcast_client_127.0.0.1:1705_00:21:6a:7d:74:fc#2', + 'unique_id': 'snapcast_client_127.0.0.1:1705_00:21:6a:7d:74:fc#1', 'unit_of_measurement': None, }) # --- -# name: test_state[test_stream_1][media_player.test_client_snapcast_client-state] +# name: test_state[media_player.test_client_1_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', + 'entity_picture': '/api/media_player_proxy/media_player.test_client_1_snapcast_client?token=mock_token&cache=6e2dee674d9d1dc7', + 'friendly_name': 'test_client_1 Snapcast Client', + 'group_members': list([ + 'media_player.test_client_1_snapcast_client', + ]), 'is_volume_muted': False, 'latency': 6, 'media_album_artist': 'Test Album Artist 1, Test Album Artist 2', @@ -60,18 +63,18 @@ 'Test Stream 1', 'Test Stream 2', ]), - 'supported_features': , + 'supported_features': , 'volume_level': 0.48, }), 'context': , - 'entity_id': 'media_player.test_client_snapcast_client', + 'entity_id': 'media_player.test_client_1_snapcast_client', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'playing', }) # --- -# name: test_state[test_stream_1][media_player.test_group_snapcast_group-entry] +# name: test_state[media_player.test_client_2_snapcast_client-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -89,7 +92,7 @@ 'disabled_by': None, 'domain': 'media_player', 'entity_category': None, - 'entity_id': 'media_player.test_group_snapcast_group', + 'entity_id': 'media_player.test_client_2_snapcast_client', 'has_entity_name': False, 'hidden_by': None, 'icon': None, @@ -101,7 +104,74 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'test_group Snapcast Group', + 'original_name': 'test_client_2 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[media_player.test_client_2_snapcast_client-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'test_client_2 Snapcast Client', + 'group_members': list([ + 'media_player.test_client_2_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': 1.0, + }), + 'context': , + 'entity_id': 'media_player.test_client_2_snapcast_client', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_state[media_player.test_group_1_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_1_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 1 Snapcast Group', 'platform': 'snapcast', 'previous_unique_id': None, 'suggested_object_id': None, @@ -111,12 +181,12 @@ 'unit_of_measurement': None, }) # --- -# name: test_state[test_stream_1][media_player.test_group_snapcast_group-state] +# name: test_state[media_player.test_group_1_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', + 'entity_picture': '/api/media_player_proxy/media_player.test_group_1_snapcast_group?token=mock_token&cache=6e2dee674d9d1dc7', + 'friendly_name': 'Test Group 1 Snapcast Group', 'is_volume_muted': False, 'media_album_artist': 'Test Album Artist 1, Test Album Artist 2', 'media_album_name': 'Test Album', @@ -135,14 +205,14 @@ 'volume_level': 0.48, }), 'context': , - 'entity_id': 'media_player.test_group_snapcast_group', + 'entity_id': 'media_player.test_group_1_snapcast_group', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'playing', }) # --- -# name: test_state[test_stream_2][media_player.test_client_snapcast_client-entry] +# name: test_state[media_player.test_group_2_snapcast_group-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -160,7 +230,7 @@ 'disabled_by': None, 'domain': 'media_player', 'entity_category': None, - 'entity_id': 'media_player.test_client_snapcast_client', + 'entity_id': 'media_player.test_group_2_snapcast_group', 'has_entity_name': False, 'hidden_by': None, 'icon': None, @@ -172,85 +242,21 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'test_client Snapcast Client', + 'original_name': 'Test Group 2 Snapcast Group', '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', + 'unique_id': 'snapcast_group_127.0.0.1:1705_4dcc4e3b-c699-a04b-7f0c-8260d23c43e2', 'unit_of_measurement': None, }) # --- -# name: test_state[test_stream_2][media_player.test_client_snapcast_client-state] +# name: test_state[media_player.test_group_2_snapcast_group-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', + 'friendly_name': 'Test Group 2 Snapcast Group', 'is_volume_muted': False, 'media_content_type': , 'source': 'test_stream_2', @@ -259,10 +265,10 @@ 'Test Stream 2', ]), 'supported_features': , - 'volume_level': 0.48, + 'volume_level': 0.65, }), 'context': , - 'entity_id': 'media_player.test_group_snapcast_group', + 'entity_id': 'media_player.test_group_2_snapcast_group', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/snapcast/test_media_player.py b/tests/components/snapcast/test_media_player.py index 57a8a865ddf..35605cb74ab 100644 --- a/tests/components/snapcast/test_media_player.py +++ b/tests/components/snapcast/test_media_player.py @@ -2,11 +2,23 @@ from unittest.mock import AsyncMock, patch +import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.media_player import ( + ATTR_GROUP_MEMBERS, + ATTR_MEDIA_VOLUME_LEVEL, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + SERVICE_UNJOIN, + SERVICE_VOLUME_SET, +) +from homeassistant.components.snapcast.const import ATTR_MASTER, DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er, issue_registry as ir from . import setup_integration @@ -28,3 +40,190 @@ async def test_state( assert mock_config_entry.state is ConfigEntryState.LOADED await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("members"), + [ + ["media_player.test_client_2_snapcast_client"], + [ + "media_player.test_client_1_snapcast_client", + "media_player.test_client_2_snapcast_client", + ], + ], +) +async def test_join( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_server: AsyncMock, + mock_group_1: AsyncMock, + mock_client_2: AsyncMock, + members: list[str], +) -> None: + """Test grouping of media players through the join service.""" + + # 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 hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_client_1_snapcast_client", + ATTR_GROUP_MEMBERS: members, + }, + blocking=True, + ) + mock_group_1.add_client.assert_awaited_once_with(mock_client_2.identifier) + + +async def test_unjoin( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_server: AsyncMock, + mock_client_1: AsyncMock, + mock_group_1: AsyncMock, +) -> None: + """Test the unjoin service removes the client from the group.""" + + # 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 hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_UNJOIN, + { + ATTR_ENTITY_ID: "media_player.test_client_1_snapcast_client", + }, + blocking=True, + ) + + mock_group_1.remove_client.assert_awaited_once_with(mock_client_1.identifier) + + +async def test_join_exception( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_create_server: AsyncMock, + mock_group_1: AsyncMock, +) -> None: + """Test join service throws an exception when trying to add a non-Snapcast client.""" + + # Create a dummy media player entity + entity_registry.async_get_or_create( + MEDIA_PLAYER_DOMAIN, + "dummy", + "media_player_1", + ) + await hass.async_block_till_done() + + # 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 + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_client_1_snapcast_client", + ATTR_GROUP_MEMBERS: ["media_player.dummy_media_player_1"], + }, + blocking=True, + ) + + # Ensure that the group did not attempt to add a non-Snapcast client + mock_group_1.add_client.assert_not_awaited() + + +async def test_legacy_join_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_config_entry: MockConfigEntry, + mock_create_server: AsyncMock, +) -> None: + """Test the legacy grouping services create issues when used.""" + + # 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 + + # Call the legacy join service + await hass.services.async_call( + DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_client_2_snapcast_client", + ATTR_MASTER: "media_player.test_client_1_snapcast_client", + }, + blocking=True, + ) + + # Verify the issue is created + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id="deprecated_grouping_actions", + ) + assert issue is not None + + # Clear existing issue + issue_registry.async_delete( + domain=DOMAIN, + issue_id="deprecated_grouping_actions", + ) + + # Call legacy unjoin service + await hass.services.async_call( + DOMAIN, + SERVICE_UNJOIN, + { + ATTR_ENTITY_ID: "media_player.test_client_2_snapcast_client", + }, + blocking=True, + ) + + # Verify the issue is created again + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id="deprecated_grouping_actions", + ) + assert issue is not None + + +async def test_deprecated_group_entity_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_config_entry: MockConfigEntry, + mock_create_server: AsyncMock, +) -> None: + """Test the legacy group entities create issues when used.""" + + # 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 + + # Call a servuce that uses a group entity + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + service_data={ + ATTR_ENTITY_ID: "media_player.test_group_1_snapcast_group", + ATTR_MEDIA_VOLUME_LEVEL: 0.5, + }, + blocking=True, + ) + + # Verify the issue is created + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id="deprecated_group_entities", + ) + assert issue is not None From 614bf96fb9d115b78f494b63590e703216bc1d11 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Aug 2025 18:09:14 +0200 Subject: [PATCH 0957/1113] Add model_id to Philips Hue (#150499) --- homeassistant/components/hue/__init__.py | 4 ++-- homeassistant/components/hue/v2/device.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index f26b11707c2..ec6f3099679 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -77,7 +77,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HueConfigEntry) -> bool: identifiers={(DOMAIN, api.config.bridge_id)}, manufacturer="Signify", name=api.config.name, - model=api.config.model_id, + model_id=api.config.model_id, sw_version=api.config.software_version, ) # create persistent notification if we found a bridge version with security vulnerability @@ -105,7 +105,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HueConfigEntry) -> bool: }, manufacturer=api.config.bridge_device.product_data.manufacturer_name, name=api.config.name, - model=api.config.model_id, + model_id=api.config.model_id, sw_version=api.config.software_version, ) diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py index 7bb3d28e962..8979befcf73 100644 --- a/homeassistant/components/hue/v2/device.py +++ b/homeassistant/components/hue/v2/device.py @@ -15,6 +15,7 @@ from homeassistant.const import ( ATTR_IDENTIFIERS, ATTR_MANUFACTURER, ATTR_MODEL, + ATTR_MODEL_ID, ATTR_NAME, ATTR_SUGGESTED_AREA, ATTR_SW_VERSION, @@ -55,12 +56,12 @@ async def async_setup_devices(bridge: HueBridge): else None, ) # Register a Hue device resource as device in HA device registry. - model = f"{hue_resource.product_data.product_name} ({hue_resource.product_data.model_id})" params = { ATTR_IDENTIFIERS: {(DOMAIN, hue_resource.id)}, ATTR_SW_VERSION: hue_resource.product_data.software_version, ATTR_NAME: hue_resource.metadata.name, - ATTR_MODEL: model, + ATTR_MODEL: hue_resource.product_data.product_name, + ATTR_MODEL_ID: hue_resource.product_data.model_id, ATTR_MANUFACTURER: hue_resource.product_data.manufacturer_name, } if room := dev_controller.get_room(hue_resource.id): From b4270e019ec644eb86bb612dcdd02e0a1e79b41d Mon Sep 17 00:00:00 2001 From: Robin Lintermann Date: Tue, 12 Aug 2025 18:14:32 +0200 Subject: [PATCH 0958/1113] Bump pysmarlaapi to 0.9.2 (#150496) --- 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 e2e9e08dcab..a99cf9b4891 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.1"] + "requirements": ["pysmarlaapi==0.9.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 35fa727fb86..54584ad3f46 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2343,7 +2343,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smarla -pysmarlaapi==0.9.1 +pysmarlaapi==0.9.2 # homeassistant.components.smartthings pysmartthings==3.2.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f8e369d098d..51a40e9ab74 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1946,7 +1946,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smarla -pysmarlaapi==0.9.1 +pysmarlaapi==0.9.2 # homeassistant.components.smartthings pysmartthings==3.2.8 From 561ef7015c87a758ec4c4639abc94a992b83e9cf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Aug 2025 18:17:44 +0200 Subject: [PATCH 0959/1113] Bump actions/checkout from 4.2.2 to 5.0.0 (#150494) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 14 +++++----- .github/workflows/ci.yaml | 42 +++++++++++++++--------------- .github/workflows/codeql.yml | 2 +- .github/workflows/translations.yml | 2 +- .github/workflows/wheels.yml | 6 ++--- 5 files changed, 33 insertions(+), 33 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 2a667f83daa..c848ac793af 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -27,7 +27,7 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 with: fetch-depth: 0 @@ -90,7 +90,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -242,7 +242,7 @@ jobs: - green steps: - name: Checkout the repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set build additional args run: | @@ -279,7 +279,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -321,7 +321,7 @@ jobs: registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"] steps: - name: Checkout the repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Install Cosign uses: sigstore/cosign-installer@v3.9.2 @@ -454,7 +454,7 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v5.6.0 @@ -499,7 +499,7 @@ jobs: HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }} steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Login to GitHub Container Registry uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 12dbe60f146..ea03f685962 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -94,7 +94,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Generate partial Python venv restore key id: generate_python_cache_key run: | @@ -246,7 +246,7 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5.6.0 @@ -292,7 +292,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v5.6.0 id: python @@ -332,7 +332,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v5.6.0 id: python @@ -372,7 +372,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v5.6.0 id: python @@ -462,7 +462,7 @@ jobs: - script/hassfest/docker/Dockerfile steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Register hadolint problem matcher run: | echo "::add-matcher::.github/workflows/matchers/hadolint.json" @@ -481,7 +481,7 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5.6.0 @@ -584,7 +584,7 @@ jobs: sudo apt-get -y install \ libturbojpeg - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5.6.0 @@ -617,7 +617,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5.6.0 @@ -651,7 +651,7 @@ jobs: && github.event_name == 'pull_request' steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Dependency review uses: actions/dependency-review-action@v4.7.1 with: @@ -674,7 +674,7 @@ jobs: python-version: ${{ fromJson(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5.6.0 @@ -717,7 +717,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5.6.0 @@ -764,7 +764,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5.6.0 @@ -809,7 +809,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5.6.0 @@ -886,7 +886,7 @@ jobs: libturbojpeg \ libgammu-dev - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5.6.0 @@ -947,7 +947,7 @@ jobs: libgammu-dev \ libxml2-utils - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5.6.0 @@ -1080,7 +1080,7 @@ jobs: libmariadb-dev-compat \ libxml2-utils - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5.6.0 @@ -1222,7 +1222,7 @@ jobs: sudo apt-get -y install \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5.6.0 @@ -1334,7 +1334,7 @@ jobs: timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Download all coverage artifacts uses: actions/download-artifact@v5.0.0 with: @@ -1381,7 +1381,7 @@ jobs: libgammu-dev \ libxml2-utils - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5.6.0 @@ -1484,7 +1484,7 @@ jobs: timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Download all coverage artifacts uses: actions/download-artifact@v5.0.0 with: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 66bd9c7ce2c..6a67a4b94de 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Initialize CodeQL uses: github/codeql-action/init@v3.29.8 diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 8a668d548d3..004b552cab3 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v5.6.0 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 3f0c0d578a9..883cc688cf5 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -32,7 +32,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python @@ -135,7 +135,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Download env_file uses: actions/download-artifact@v5.0.0 @@ -184,7 +184,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Download env_file uses: actions/download-artifact@v5.0.0 From c1e5a7efc9d8a9270c7025ec11fd015011747daa Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 12 Aug 2025 18:23:27 +0200 Subject: [PATCH 0960/1113] Add icons to Sleep as Android sensor entities (#150451) --- homeassistant/components/sleep_as_android/icons.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/sleep_as_android/icons.json b/homeassistant/components/sleep_as_android/icons.json index 001cb5ec561..0565716a5f1 100644 --- a/homeassistant/components/sleep_as_android/icons.json +++ b/homeassistant/components/sleep_as_android/icons.json @@ -25,6 +25,14 @@ "sleep_health": { "default": "mdi:heart-pulse" } + }, + "sensor": { + "alarm_time": { + "default": "mdi:alarm" + }, + "alarm_label": { + "default": "mdi:label-outline" + } } } } From 2ebe0a929e309e463a5e5ad21bccefb87d04abcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 12 Aug 2025 19:10:55 +0200 Subject: [PATCH 0961/1113] Matter SmokeCoAlarm SelfTestRequest (#150497) --- homeassistant/components/matter/button.py | 12 +++++ homeassistant/components/matter/icons.json | 3 ++ homeassistant/components/matter/strings.json | 3 ++ .../matter/snapshots/test_button.ambr | 48 +++++++++++++++++++ tests/components/matter/test_button.py | 27 +++++++++++ 5 files changed, 93 insertions(+) diff --git a/homeassistant/components/matter/button.py b/homeassistant/components/matter/button.py index 6a0a5fc5b1d..f75c5063c06 100644 --- a/homeassistant/components/matter/button.py +++ b/homeassistant/components/matter/button.py @@ -143,4 +143,16 @@ DISCOVERY_SCHEMAS = [ value_contains=clusters.ActivatedCarbonFilterMonitoring.Commands.ResetCondition.command_id, allow_multi=True, ), + MatterDiscoverySchema( + platform=Platform.BUTTON, + entity_description=MatterButtonEntityDescription( + key="SmokeCoAlarmSelfTestRequest", + translation_key="self_test_request", + entity_category=EntityCategory.DIAGNOSTIC, + command=clusters.SmokeCoAlarm.Commands.SelfTestRequest, + ), + entity_class=MatterCommandButton, + required_attributes=(clusters.SmokeCoAlarm.Attributes.AcceptedCommandList,), + value_contains=clusters.SmokeCoAlarm.Commands.SelfTestRequest.command_id, + ), ] diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 475504d5aeb..4bf2350738f 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -17,6 +17,9 @@ }, "stop": { "default": "mdi:stop" + }, + "self_test_request": { + "default": "mdi:refresh-auto" } }, "fan": { diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 749cf387a40..6355ebfbee6 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -111,6 +111,9 @@ }, "reset_filter_condition": { "name": "Reset filter condition" + }, + "self_test_request": { + "name": "Self-test" } }, "climate": { diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index 2ffbd248290..f70c38f6b6d 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -2038,6 +2038,54 @@ 'state': 'unknown', }) # --- +# name: test_buttons[smoke_detector][button.smoke_sensor_self_test-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.smoke_sensor_self_test', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Self-test', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'self_test_request', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-SmokeCoAlarmSelfTestRequest-92-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[smoke_detector][button.smoke_sensor_self_test-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smoke sensor Self-test', + }), + 'context': , + 'entity_id': 'button.smoke_sensor_self_test', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[switch_unit][button.mock_switchunit_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_button.py b/tests/components/matter/test_button.py index 2af2d40cb74..6452dabc10d 100644 --- a/tests/components/matter/test_button.py +++ b/tests/components/matter/test_button.py @@ -80,3 +80,30 @@ async def test_operational_state_buttons( endpoint_id=1, command=clusters.OperationalState.Commands.Pause(), ) + + +@pytest.mark.parametrize("node_fixture", ["smoke_detector"]) +async def test_smoke_detector_self_test( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test button entity is created for a Matter SmokeCoAlarm Cluster.""" + state = hass.states.get("button.smoke_sensor_self_test") + assert state + assert state.attributes["friendly_name"] == "Smoke sensor Self-test" + # test press action + await hass.services.async_call( + "button", + "press", + { + "entity_id": "button.smoke_sensor_self_test", + }, + 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.SmokeCoAlarm.Commands.SelfTestRequest(), + ) From fb68b2d454a3a222294caa47e398a217d1e343fd Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 12 Aug 2025 19:27:27 +0200 Subject: [PATCH 0962/1113] Bump airOS to 0.2.8 (#150504) --- homeassistant/components/airos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airos/snapshots/test_diagnostics.ambr | 5 +++++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airos/manifest.json b/homeassistant/components/airos/manifest.json index 84003c19b89..58f76abe577 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.7"] + "requirements": ["airos==0.2.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 54584ad3f46..bbc398d77ff 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.7 +airos==0.2.8 # homeassistant.components.airthings_ble airthings-ble==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 51a40e9ab74..0554d7c0a08 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.7 +airos==0.2.8 # homeassistant.components.airthings_ble airthings-ble==0.9.2 diff --git a/tests/components/airos/snapshots/test_diagnostics.ambr b/tests/components/airos/snapshots/test_diagnostics.ambr index 574dbf68949..e3c4d74a5fd 100644 --- a/tests/components/airos/snapshots/test_diagnostics.ambr +++ b/tests/components/airos/snapshots/test_diagnostics.ambr @@ -28,9 +28,14 @@ }), 'genuine': '/images/genuine.png', 'gps': dict({ + 'alt': None, + 'dim': None, + 'dop': None, 'fix': 0, 'lat': '**REDACTED**', 'lon': '**REDACTED**', + 'sats': None, + 'time_synced': None, }), 'host': dict({ 'cpuload': 10.10101, From ea946c90b3d969ceb41d4edebded95c23b9be55d Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 12 Aug 2025 19:38:17 +0200 Subject: [PATCH 0963/1113] Modbus: Cancel connect background task if stopping/restarting. (#150507) --- homeassistant/components/modbus/modbus.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 5ddde2973ee..9e0ba63b4a0 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -262,6 +262,7 @@ class ModbusHub: self._config_type = client_config[CONF_TYPE] self._config_delay = client_config[CONF_DELAY] self._pb_request: dict[str, RunEntry] = {} + self._connect_task: asyncio.Task self._pb_class = { SERIAL: AsyncModbusSerialClient, TCP: AsyncModbusTcpClient, @@ -336,7 +337,7 @@ class ModbusHub: entry.attr, func, entry.value_attr_name ) - self.hass.async_create_background_task( + self._connect_task = self.hass.async_create_background_task( self.async_pb_connect(), "modbus-connect" ) @@ -365,6 +366,9 @@ class ModbusHub: if self._async_cancel_listener: self._async_cancel_listener() self._async_cancel_listener = None + if not self._connect_task.done(): + self._connect_task.cancel() + async with self._lock: if self._client: try: From 4d426c31f9936db0346df5211258312ea6fa0f98 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 12 Aug 2025 20:10:43 +0200 Subject: [PATCH 0964/1113] Fix missing sentence-case in `hydrawise` (#150513) --- homeassistant/components/hydrawise/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hydrawise/strings.json b/homeassistant/components/hydrawise/strings.json index 47543aa2f8f..29b6d741a5e 100644 --- a/homeassistant/components/hydrawise/strings.json +++ b/homeassistant/components/hydrawise/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Hydrawise Login", + "title": "Hydrawise login", "description": "Please provide the username and password for your Hydrawise cloud account:", "data": { "username": "[%key:common::config_flow::data::username%]", @@ -10,7 +10,7 @@ "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "api_key": "You can generate an API Key in the 'Account Details' section of the Hydrawise app" + "api_key": "You can generate an API key in the 'Account Details' section of the Hydrawise app" } }, "reauth_confirm": { From 9fdc63278097e4e327063fc98c542316087d1138 Mon Sep 17 00:00:00 2001 From: Yevhenii Vaskivskyi Date: Tue, 12 Aug 2025 20:45:39 +0200 Subject: [PATCH 0965/1113] Switch asuswrt http(s) library to asusrouter package (#150426) --- CODEOWNERS | 4 +- homeassistant/components/asuswrt/bridge.py | 177 +++++++++--------- .../components/asuswrt/config_flow.py | 4 +- homeassistant/components/asuswrt/helpers.py | 56 ++++++ .../components/asuswrt/manifest.json | 4 +- homeassistant/components/asuswrt/router.py | 6 +- requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- tests/components/asuswrt/common.py | 21 ++- tests/components/asuswrt/conftest.py | 163 +++++++++++----- tests/components/asuswrt/test_config_flow.py | 13 +- tests/components/asuswrt/test_helpers.py | 95 ++++++++++ tests/components/asuswrt/test_sensor.py | 30 ++- 13 files changed, 414 insertions(+), 171 deletions(-) create mode 100644 homeassistant/components/asuswrt/helpers.py create mode 100644 tests/components/asuswrt/test_helpers.py diff --git a/CODEOWNERS b/CODEOWNERS index b9a8367ba3e..44c9d7d4547 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -156,8 +156,8 @@ build.json @home-assistant/supervisor /tests/components/assist_pipeline/ @balloob @synesthesiam /homeassistant/components/assist_satellite/ @home-assistant/core @synesthesiam /tests/components/assist_satellite/ @home-assistant/core @synesthesiam -/homeassistant/components/asuswrt/ @kennedyshead @ollo69 -/tests/components/asuswrt/ @kennedyshead @ollo69 +/homeassistant/components/asuswrt/ @kennedyshead @ollo69 @Vaskivskyi +/tests/components/asuswrt/ @kennedyshead @ollo69 @Vaskivskyi /homeassistant/components/atag/ @MatsNL /tests/components/atag/ @MatsNL /homeassistant/components/aten_pe/ @mtdcr diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py index bc6f0fe6fd2..b5042d07b82 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -5,15 +5,16 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections import namedtuple from collections.abc import Awaitable, Callable, Coroutine -from datetime import datetime import functools import logging from typing import Any, cast from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy from aiohttp import ClientSession -from pyasuswrt import AsusWrtError, AsusWrtHttp -from pyasuswrt.exceptions import AsusWrtNotAvailableInfoError +from asusrouter import AsusRouter, AsusRouterError +from asusrouter.modules.client import AsusClient +from asusrouter.modules.data import AsusData +from asusrouter.modules.homeassistant import convert_to_ha_data, convert_to_ha_sensors from homeassistant.const import ( CONF_HOST, @@ -41,14 +42,13 @@ from .const import ( PROTOCOL_HTTPS, PROTOCOL_TELNET, SENSORS_BYTES, - SENSORS_CPU, SENSORS_LOAD_AVG, SENSORS_MEMORY, SENSORS_RATES, - SENSORS_TEMPERATURES, SENSORS_TEMPERATURES_LEGACY, SENSORS_UPTIME, ) +from .helpers import clean_dict, translate_to_legacy SENSORS_TYPE_BYTES = "sensors_bytes" SENSORS_TYPE_COUNT = "sensors_count" @@ -310,16 +310,16 @@ class AsusWrtHttpBridge(AsusWrtBridge): def __init__(self, conf: dict[str, Any], session: ClientSession) -> None: """Initialize Bridge that use HTTP library.""" super().__init__(conf[CONF_HOST]) - self._api: AsusWrtHttp = self._get_api(conf, session) + self._api = self._get_api(conf, session) @staticmethod - def _get_api(conf: dict[str, Any], session: ClientSession) -> AsusWrtHttp: - """Get the AsusWrtHttp API.""" - return AsusWrtHttp( - conf[CONF_HOST], - conf[CONF_USERNAME], - conf.get(CONF_PASSWORD, ""), - use_https=conf[CONF_PROTOCOL] == PROTOCOL_HTTPS, + def _get_api(conf: dict[str, Any], session: ClientSession) -> AsusRouter: + """Get the AsusRouter API.""" + return AsusRouter( + hostname=conf[CONF_HOST], + username=conf[CONF_USERNAME], + password=conf.get(CONF_PASSWORD, ""), + use_ssl=conf[CONF_PROTOCOL] == PROTOCOL_HTTPS, port=conf.get(CONF_PORT), session=session, ) @@ -327,46 +327,90 @@ class AsusWrtHttpBridge(AsusWrtBridge): @property def is_connected(self) -> bool: """Get connected status.""" - return cast(bool, self._api.is_connected) + return self._api.connected async def async_connect(self) -> None: """Connect to the device.""" await self._api.async_connect() + # Collect the identity + _identity = await self._api.async_get_identity() + # get main router properties - if mac := self._api.mac: + if mac := _identity.mac: self._label_mac = format_mac(mac) - self._firmware = self._api.firmware - self._model = self._api.model + self._firmware = str(_identity.firmware) + self._model = _identity.model async def async_disconnect(self) -> None: """Disconnect to the device.""" await self._api.async_disconnect() + async def _get_data( + self, + datatype: AsusData, + force: bool = False, + ) -> dict[str, Any]: + """Get data from the device. + + This is a generic method which automatically converts to + the Home Assistant-compatible format. + """ + try: + raw = await self._api.async_get_data(datatype, force=force) + return translate_to_legacy(clean_dict(convert_to_ha_data(raw))) + except AsusRouterError as ex: + raise UpdateFailed(ex) from ex + + async def _get_sensors(self, datatype: AsusData) -> list[str]: + """Get the available sensors. + + This is a generic method which automatically converts to + the Home Assistant-compatible format. + """ + sensors = [] + try: + data = await self._api.async_get_data(datatype) + # Get the list of sensors from the raw data + # and translate in to the legacy format + sensors = translate_to_legacy(convert_to_ha_sensors(data, datatype)) + _LOGGER.debug("Available `%s` sensors: %s", datatype.value, sensors) + except AsusRouterError as ex: + _LOGGER.warning( + "Cannot get available `%s` sensors with exception: %s", + datatype.value, + ex, + ) + return sensors + async def async_get_connected_devices(self) -> dict[str, WrtDevice]: """Get list of connected devices.""" - api_devices = await self._api.async_get_connected_devices() + api_devices: dict[str, AsusClient] = await self._api.async_get_data( + AsusData.CLIENTS, force=True + ) return { - format_mac(mac): WrtDevice(dev.ip, dev.name, dev.node) + format_mac(mac): WrtDevice( + dev.connection.ip_address, dev.description.name, dev.connection.node + ) for mac, dev in api_devices.items() + if dev.connection is not None + and dev.description is not None + and dev.connection.ip_address is not None } async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]: """Return a dictionary of available sensors for this bridge.""" - sensors_cpu = await self._get_available_cpu_sensors() - sensors_temperatures = await self._get_available_temperature_sensors() - sensors_loadavg = await self._get_loadavg_sensors_availability() return { SENSORS_TYPE_BYTES: { KEY_SENSORS: SENSORS_BYTES, KEY_METHOD: self._get_bytes, }, SENSORS_TYPE_CPU: { - KEY_SENSORS: sensors_cpu, + KEY_SENSORS: await self._get_sensors(AsusData.CPU), KEY_METHOD: self._get_cpu_usage, }, SENSORS_TYPE_LOAD_AVG: { - KEY_SENSORS: sensors_loadavg, + KEY_SENSORS: await self._get_sensors(AsusData.SYSINFO), KEY_METHOD: self._get_load_avg, }, SENSORS_TYPE_MEMORY: { @@ -382,95 +426,44 @@ class AsusWrtHttpBridge(AsusWrtBridge): KEY_METHOD: self._get_uptime, }, SENSORS_TYPE_TEMPERATURES: { - KEY_SENSORS: sensors_temperatures, + KEY_SENSORS: await self._get_sensors(AsusData.TEMPERATURE), KEY_METHOD: self._get_temperatures, }, } - async def _get_available_cpu_sensors(self) -> list[str]: - """Check which cpu information is available on the router.""" - try: - available_cpu = await self._api.async_get_cpu_usage() - available_sensors = [t for t in SENSORS_CPU if t in available_cpu] - except AsusWrtError as exc: - _LOGGER.warning( - ( - "Failed checking cpu sensor availability for ASUS router" - " %s. Exception: %s" - ), - self.host, - exc, - ) - return [] - return available_sensors - - async def _get_available_temperature_sensors(self) -> list[str]: - """Check which temperature information is available on the router.""" - try: - available_temps = await self._api.async_get_temperatures() - available_sensors = [ - t for t in SENSORS_TEMPERATURES if t in available_temps - ] - except AsusWrtError as exc: - _LOGGER.warning( - ( - "Failed checking temperature sensor availability for ASUS router" - " %s. Exception: %s" - ), - self.host, - exc, - ) - return [] - return available_sensors - - async def _get_loadavg_sensors_availability(self) -> list[str]: - """Check if load avg is available on the router.""" - try: - await self._api.async_get_loadavg() - except AsusWrtNotAvailableInfoError: - return [] - except AsusWrtError: - pass - return SENSORS_LOAD_AVG - - @handle_errors_and_zip(AsusWrtError, SENSORS_BYTES) async def _get_bytes(self) -> Any: """Fetch byte information from the router.""" - return await self._api.async_get_traffic_bytes() + return await self._get_data(AsusData.NETWORK) - @handle_errors_and_zip(AsusWrtError, SENSORS_RATES) async def _get_rates(self) -> Any: """Fetch rates information from the router.""" - return await self._api.async_get_traffic_rates() + data = await self._get_data(AsusData.NETWORK) + # Convert from bits/s to Bytes/s for compatibility with legacy sensors + return { + key: ( + value / 8 + if key in SENSORS_RATES and isinstance(value, (int, float)) + else value + ) + for key, value in data.items() + } - @handle_errors_and_zip(AsusWrtError, SENSORS_LOAD_AVG) async def _get_load_avg(self) -> Any: """Fetch cpu load avg information from the router.""" - return await self._api.async_get_loadavg() + return await self._get_data(AsusData.SYSINFO) - @handle_errors_and_zip(AsusWrtError, None) async def _get_temperatures(self) -> Any: """Fetch temperatures information from the router.""" - return await self._api.async_get_temperatures() + return await self._get_data(AsusData.TEMPERATURE) - @handle_errors_and_zip(AsusWrtError, None) async def _get_cpu_usage(self) -> Any: """Fetch cpu information from the router.""" - return await self._api.async_get_cpu_usage() + return await self._get_data(AsusData.CPU) - @handle_errors_and_zip(AsusWrtError, None) async def _get_memory_usage(self) -> Any: """Fetch memory information from the router.""" - return await self._api.async_get_memory_usage() + return await self._get_data(AsusData.RAM) async def _get_uptime(self) -> dict[str, Any]: """Fetch uptime from the router.""" - try: - uptimes = await self._api.async_get_uptime() - except AsusWrtError as exc: - raise UpdateFailed(exc) from exc - - last_boot = datetime.fromisoformat(uptimes["last_boot"]) - uptime = uptimes["uptime"] - - return dict(zip(SENSORS_UPTIME, [last_boot, uptime], strict=False)) + return await self._get_data(AsusData.BOOTTIME) diff --git a/homeassistant/components/asuswrt/config_flow.py b/homeassistant/components/asuswrt/config_flow.py index d58a216aaee..a86f7e08318 100644 --- a/homeassistant/components/asuswrt/config_flow.py +++ b/homeassistant/components/asuswrt/config_flow.py @@ -7,7 +7,7 @@ import os import socket from typing import Any, cast -from pyasuswrt import AsusWrtError +from asusrouter import AsusRouterError import voluptuous as vol from homeassistant.components.device_tracker import ( @@ -189,7 +189,7 @@ class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN): try: await api.async_connect() - except (AsusWrtError, OSError): + except (AsusRouterError, OSError): _LOGGER.error( "Error connecting to the AsusWrt router at %s using protocol %s", host, diff --git a/homeassistant/components/asuswrt/helpers.py b/homeassistant/components/asuswrt/helpers.py new file mode 100644 index 00000000000..0fb467e6046 --- /dev/null +++ b/homeassistant/components/asuswrt/helpers.py @@ -0,0 +1,56 @@ +"""Helpers for AsusWRT integration.""" + +from __future__ import annotations + +from typing import Any, TypeVar + +T = TypeVar("T", dict[str, Any], list[Any], None) + +TRANSLATION_MAP = { + "wan_rx": "sensor_rx_bytes", + "wan_tx": "sensor_tx_bytes", + "total_usage": "cpu_total_usage", + "usage": "mem_usage_perc", + "free": "mem_free", + "used": "mem_used", + "wan_rx_speed": "sensor_rx_rates", + "wan_tx_speed": "sensor_tx_rates", + "2ghz": "2.4GHz", + "5ghz": "5.0GHz", + "5ghz2": "5.0GHz_2", + "6ghz": "6.0GHz", + "cpu": "CPU", + "datetime": "sensor_last_boot", + "uptime": "sensor_uptime", + **{f"{num}_usage": f"cpu{num}_usage" for num in range(1, 9)}, + **{f"load_avg_{load}": f"sensor_load_avg{load}" for load in ("1", "5", "15")}, +} + + +def clean_dict(raw: dict[str, Any]) -> dict[str, Any]: + """Cleans dictionary from None values. + + The `state` key is always preserved regardless of its value. + """ + + return {k: v for k, v in raw.items() if v is not None or k.endswith("state")} + + +def translate_to_legacy(raw: T) -> T: + """Translate raw data to legacy format for dicts and lists.""" + + if raw is None: + return None + + if isinstance(raw, dict): + return {TRANSLATION_MAP.get(k, k): v for k, v in raw.items()} + + if isinstance(raw, list): + return [ + TRANSLATION_MAP[item] + if isinstance(item, str) and item in TRANSLATION_MAP + else item + for item in raw + ] + + return raw diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json index f4b2e3386e9..5064642619c 100644 --- a/homeassistant/components/asuswrt/manifest.json +++ b/homeassistant/components/asuswrt/manifest.json @@ -1,11 +1,11 @@ { "domain": "asuswrt", "name": "ASUSWRT", - "codeowners": ["@kennedyshead", "@ollo69"], + "codeowners": ["@kennedyshead", "@ollo69", "@Vaskivskyi"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/asuswrt", "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioasuswrt", "asyncssh"], - "requirements": ["aioasuswrt==1.4.0", "pyasuswrt==0.1.21"] + "requirements": ["aioasuswrt==1.4.0", "asusrouter==1.18.1"] } diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index 3cf8d2e863d..c777535e242 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -7,7 +7,7 @@ from datetime import datetime, timedelta import logging from typing import TYPE_CHECKING, Any -from pyasuswrt import AsusWrtError +from asusrouter import AsusRouterError from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, @@ -229,7 +229,7 @@ class AsusWrtRouter: """Set up a AsusWrt router.""" try: await self._api.async_connect() - except (AsusWrtError, OSError) as exc: + except (AsusRouterError, OSError) as exc: raise ConfigEntryNotReady from exc if not self._api.is_connected: raise ConfigEntryNotReady @@ -284,7 +284,7 @@ class AsusWrtRouter: _LOGGER.debug("Checking devices for ASUS router %s", self.host) try: wrt_devices = await self._api.async_get_connected_devices() - except (OSError, AsusWrtError) as exc: + except (OSError, AsusRouterError) as exc: if not self._connect_error: self._connect_error = True _LOGGER.error( diff --git a/requirements_all.txt b/requirements_all.txt index bbc398d77ff..a79b4450f5d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -527,6 +527,9 @@ arris-tg2492lg==2.2.0 # homeassistant.components.ampio asmog==0.0.6 +# homeassistant.components.asuswrt +asusrouter==1.18.1 + # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms # homeassistant.components.samsungtv @@ -1839,9 +1842,6 @@ pyairvisual==2023.08.1 # homeassistant.components.aprilaire pyaprilaire==0.9.1 -# homeassistant.components.asuswrt -pyasuswrt==0.1.21 - # homeassistant.components.atag pyatag==0.3.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0554d7c0a08..b0afbc75649 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -491,6 +491,9 @@ aranet4==2.5.1 # homeassistant.components.arcam_fmj arcam-fmj==1.8.2 +# homeassistant.components.asuswrt +asusrouter==1.18.1 + # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms # homeassistant.components.samsungtv @@ -1544,9 +1547,6 @@ pyairvisual==2023.08.1 # homeassistant.components.aprilaire pyaprilaire==0.9.1 -# homeassistant.components.asuswrt -pyasuswrt==0.1.21 - # homeassistant.components.atag pyatag==0.3.5.3 diff --git a/tests/components/asuswrt/common.py b/tests/components/asuswrt/common.py index d3953416281..541e74e5b39 100644 --- a/tests/components/asuswrt/common.py +++ b/tests/components/asuswrt/common.py @@ -1,7 +1,8 @@ """Test code shared between test files.""" +from unittest.mock import MagicMock + from aioasuswrt.asuswrt import Device as LegacyDevice -from pyasuswrt.asuswrt import Device as HttpDevice from homeassistant.components.asuswrt.const import ( CONF_SSH_KEY, @@ -59,8 +60,22 @@ MOCK_MACS = [ ] -def new_device(protocol, mac, ip, name): +def make_client(mac, ip, name, node): + """Create a modern mock client.""" + connection = MagicMock() + connection.ip_address = ip + connection.node = node + description = MagicMock() + description.name = name + description.mac = mac + client = MagicMock() + client.connection = connection + client.description = description + return client + + +def new_device(protocol, mac, ip, name, node=None): """Return a new device for specific protocol.""" if protocol in [PROTOCOL_HTTP, PROTOCOL_HTTPS]: - return HttpDevice(mac, ip, name, ROUTER_MAC_ADDR, None) + return make_client(mac, ip, name, node) return LegacyDevice(mac, ip, name) diff --git a/tests/components/asuswrt/conftest.py b/tests/components/asuswrt/conftest.py index f850a26b997..e6dd42a23fd 100644 --- a/tests/components/asuswrt/conftest.py +++ b/tests/components/asuswrt/conftest.py @@ -1,17 +1,25 @@ """Fixtures for Asuswrt component.""" +from datetime import datetime from unittest.mock import Mock, patch from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy from aioasuswrt.connection import TelnetConnection -from pyasuswrt.asuswrt import AsusWrtError, AsusWrtHttp +from asusrouter import AsusRouter, AsusRouterError +from asusrouter.modules.data import AsusData +from asusrouter.modules.identity import AsusDevice import pytest -from homeassistant.components.asuswrt.const import PROTOCOL_HTTP, PROTOCOL_SSH +from .common import ( + ASUSWRT_BASE, + MOCK_MACS, + PROTOCOL_HTTP, + PROTOCOL_SSH, + ROUTER_MAC_ADDR, + new_device, +) -from .common import ASUSWRT_BASE, MOCK_MACS, ROUTER_MAC_ADDR, new_device - -ASUSWRT_HTTP_LIB = f"{ASUSWRT_BASE}.bridge.AsusWrtHttp" +ASUSWRT_HTTP_LIB = f"{ASUSWRT_BASE}.bridge.AsusRouter" ASUSWRT_LEGACY_LIB = f"{ASUSWRT_BASE}.bridge.AsusWrtLegacy" MOCK_BYTES_TOTAL = 60000000000, 50000000000 @@ -29,8 +37,20 @@ MOCK_CPU_USAGE = { } MOCK_CURRENT_TRANSFER_RATES = 20000000, 10000000 MOCK_CURRENT_TRANSFER_RATES_HTTP = dict(enumerate(MOCK_CURRENT_TRANSFER_RATES)) +# Mock for AsusData.NETWORK return of both rates and total bytes +MOCK_CURRENT_NETWORK = { + "sensor_rx_rates": MOCK_CURRENT_TRANSFER_RATES[0] * 8, # AR works with bits + "sensor_tx_rates": MOCK_CURRENT_TRANSFER_RATES[1] * 8, # AR works with bits + "sensor_rx_bytes": MOCK_BYTES_TOTAL[0], + "sensor_tx_bytes": MOCK_BYTES_TOTAL[1], +} MOCK_LOAD_AVG_HTTP = {"load_avg_1": 1.1, "load_avg_5": 1.2, "load_avg_15": 1.3} MOCK_LOAD_AVG = list(MOCK_LOAD_AVG_HTTP.values()) +MOCK_SYSINFO = { + "sensor_load_avg1": MOCK_LOAD_AVG[0], + "sensor_load_avg5": MOCK_LOAD_AVG[1], + "sensor_load_avg15": MOCK_LOAD_AVG[2], +} MOCK_MEMORY_USAGE = { "mem_usage_perc": 52.4, "mem_total": 1048576, @@ -40,6 +60,10 @@ MOCK_MEMORY_USAGE = { MOCK_TEMPERATURES = {"2.4GHz": 40.2, "5.0GHz": 0, "CPU": 71.2} MOCK_TEMPERATURES_HTTP = {**MOCK_TEMPERATURES, "5.0GHz_2": 40.3, "6.0GHz": 40.4} MOCK_UPTIME = {"last_boot": "2024-08-02T00:47:00+00:00", "uptime": 1625927} +MOCK_BOOTTIME = { + "sensor_last_boot": datetime.fromisoformat(MOCK_UPTIME["last_boot"]), + "sensor_uptime": MOCK_UPTIME["uptime"], +} @pytest.fixture(name="patch_setup_entry") @@ -62,10 +86,14 @@ def mock_devices_legacy_fixture(): @pytest.fixture(name="mock_devices_http") def mock_devices_http_fixture(): - """Mock a list of devices.""" + """Mock a list of AsusRouter client devices for HTTP backend.""" return { - MOCK_MACS[0]: new_device(PROTOCOL_HTTP, MOCK_MACS[0], "192.168.1.2", "Test"), - MOCK_MACS[1]: new_device(PROTOCOL_HTTP, MOCK_MACS[1], "192.168.1.3", "TestTwo"), + MOCK_MACS[0]: new_device( + PROTOCOL_HTTP, MOCK_MACS[0], "192.168.1.2", "Test", "node1" + ), + MOCK_MACS[1]: new_device( + PROTOCOL_HTTP, MOCK_MACS[1], "192.168.1.3", "TestTwo", "node2" + ), } @@ -121,57 +149,90 @@ def mock_controller_connect_legacy_sens_fail(connect_legacy): @pytest.fixture(name="connect_http") def mock_controller_connect_http(mock_devices_http): """Mock a successful connection with http library.""" - with patch(ASUSWRT_HTTP_LIB, spec_set=AsusWrtHttp) as service_mock: - service_mock.return_value.is_connected = True - service_mock.return_value.mac = ROUTER_MAC_ADDR - service_mock.return_value.model = "FAKE_MODEL" - service_mock.return_value.firmware = "FAKE_FIRMWARE" - service_mock.return_value.async_get_connected_devices.return_value = ( - mock_devices_http + with patch(ASUSWRT_HTTP_LIB, spec_set=AsusRouter) as service_mock: + instance = service_mock.return_value + + # Simulate connection status + instance.connected = True + + # Identity + instance.async_get_identity.return_value = AsusDevice( + mac=ROUTER_MAC_ADDR, + model="FAKE_MODEL", + firmware="FAKE_FIRMWARE", ) - service_mock.return_value.async_get_traffic_bytes.return_value = ( - MOCK_BYTES_TOTAL_HTTP - ) - service_mock.return_value.async_get_traffic_rates.return_value = ( - MOCK_CURRENT_TRANSFER_RATES_HTTP - ) - service_mock.return_value.async_get_loadavg.return_value = MOCK_LOAD_AVG_HTTP - service_mock.return_value.async_get_temperatures.return_value = { - k: v for k, v in MOCK_TEMPERATURES_HTTP.items() if k != "5.0GHz" - } - service_mock.return_value.async_get_cpu_usage.return_value = MOCK_CPU_USAGE - service_mock.return_value.async_get_memory_usage.return_value = ( - MOCK_MEMORY_USAGE - ) - service_mock.return_value.async_get_uptime.return_value = MOCK_UPTIME + + # Data fetches via async_get_data + instance.async_get_data.side_effect = lambda datatype, *args, **kwargs: { + AsusData.CLIENTS: mock_devices_http, + AsusData.NETWORK: MOCK_CURRENT_NETWORK, + AsusData.SYSINFO: MOCK_SYSINFO, + AsusData.TEMPERATURE: { + k: v for k, v in MOCK_TEMPERATURES_HTTP.items() if k != "5.0GHz" + }, + AsusData.CPU: MOCK_CPU_USAGE, + AsusData.RAM: MOCK_MEMORY_USAGE, + AsusData.BOOTTIME: MOCK_BOOTTIME, + }[datatype] + yield service_mock +def make_async_get_data_side_effect(fail_types=None): + """Return a side effect for async_get_data that fails for specified AsusData types.""" + fail_types = set(fail_types or []) + + def side_effect(datatype, *args, **kwargs): + if datatype in fail_types: + raise AsusRouterError(f"{datatype} unavailable") + # Return valid mock data for other types + if datatype == AsusData.CLIENTS: + return {} + if datatype == AsusData.NETWORK: + return {} + if datatype == AsusData.SYSINFO: + return {} + if datatype == AsusData.TEMPERATURE: + return {} + if datatype == AsusData.CPU: + return {} + if datatype == AsusData.RAM: + return {} + if datatype == AsusData.BOOTTIME: + return {} + return {} + + return side_effect + + @pytest.fixture(name="connect_http_sens_fail") def mock_controller_connect_http_sens_fail(connect_http): - """Mock a successful connection using http library with sensors fail.""" - connect_http.return_value.mac = None - connect_http.return_value.async_get_connected_devices.side_effect = AsusWrtError - connect_http.return_value.async_get_traffic_bytes.side_effect = AsusWrtError - connect_http.return_value.async_get_traffic_rates.side_effect = AsusWrtError - connect_http.return_value.async_get_loadavg.side_effect = AsusWrtError - connect_http.return_value.async_get_temperatures.side_effect = AsusWrtError - connect_http.return_value.async_get_cpu_usage.side_effect = AsusWrtError - connect_http.return_value.async_get_memory_usage.side_effect = AsusWrtError - connect_http.return_value.async_get_uptime.side_effect = AsusWrtError + """Universal fixture to fail specified AsusData types.""" + + def _set_fail_types(fail_types): + connect_http.return_value.async_get_data.side_effect = ( + make_async_get_data_side_effect(fail_types) + ) + return connect_http + + return _set_fail_types @pytest.fixture(name="connect_http_sens_detect") def mock_controller_connect_http_sens_detect(): """Mock a successful sensor detection using http library.""" - with ( - patch( - f"{ASUSWRT_BASE}.bridge.AsusWrtHttpBridge._get_available_temperature_sensors", - return_value=[*MOCK_TEMPERATURES_HTTP], - ) as mock_sens_temp_detect, - patch( - f"{ASUSWRT_BASE}.bridge.AsusWrtHttpBridge._get_available_cpu_sensors", - return_value=[*MOCK_CPU_USAGE], - ) as mock_sens_cpu_detect, - ): - yield mock_sens_temp_detect, mock_sens_cpu_detect + + def _get_sensors_side_effect(datatype): + if datatype == AsusData.TEMPERATURE: + return list(MOCK_TEMPERATURES_HTTP) + if datatype == AsusData.CPU: + return list(MOCK_CPU_USAGE) + if datatype == AsusData.SYSINFO: + return list(MOCK_SYSINFO) + return [] + + with patch( + f"{ASUSWRT_BASE}.bridge.AsusWrtHttpBridge._get_sensors", + side_effect=_get_sensors_side_effect, + ) as mock_sens_detect: + yield mock_sens_detect diff --git a/tests/components/asuswrt/test_config_flow.py b/tests/components/asuswrt/test_config_flow.py index 83c3204d239..314bf030dbc 100644 --- a/tests/components/asuswrt/test_config_flow.py +++ b/tests/components/asuswrt/test_config_flow.py @@ -3,7 +3,8 @@ from socket import gaierror from unittest.mock import patch -from pyasuswrt import AsusWrtError +from asusrouter import AsusRouterError +from asusrouter.modules.identity import AsusDevice import pytest from homeassistant.components.asuswrt.const import ( @@ -128,7 +129,11 @@ async def test_user_http( assert flow_result["type"] is FlowResultType.FORM assert flow_result["step_id"] == "user" - connect_http.return_value.mac = unique_id + connect_http.return_value.async_get_identity.return_value = AsusDevice( + mac=unique_id, + model="FAKE_MODEL", + firmware="FAKE_FIRMWARE", + ) # test with all provided result = await hass.config_entries.flow.async_configure( @@ -297,7 +302,7 @@ async def test_on_connect_legacy_failed( @pytest.mark.parametrize( ("side_effect", "error"), [ - (AsusWrtError, "cannot_connect"), + (AsusRouterError, "cannot_connect"), (TypeError, "unknown"), (None, "cannot_connect"), ], @@ -311,7 +316,7 @@ async def test_on_connect_http_failed( context={"source": SOURCE_USER, "show_advanced_options": True}, ) - connect_http.return_value.is_connected = False + connect_http.return_value.connected = False connect_http.return_value.async_connect.side_effect = side_effect result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/asuswrt/test_helpers.py b/tests/components/asuswrt/test_helpers.py new file mode 100644 index 00000000000..6573ab9361c --- /dev/null +++ b/tests/components/asuswrt/test_helpers.py @@ -0,0 +1,95 @@ +"""Tests for AsusWRT helpers.""" + +from typing import Any + +import pytest + +from homeassistant.components.asuswrt.helpers import clean_dict, translate_to_legacy + +DICT_TO_CLEAN = { + "key1": "value1", + "key2": None, + "key3_state": "value3", + "key4_state": None, + "state": None, +} + +DICT_CLEAN = { + "key1": "value1", + "key3_state": "value3", + "key4_state": None, + "state": None, +} + +TRANSLATE_0_INPUT = { + "usage": "value1", + "cpu": "value2", +} + +TRANSLATE_0_OUTPUT = { + "mem_usage_perc": "value1", + "CPU": "value2", +} + +TRANSLATE_1_INPUT = { + "wan_rx": "value1", + "wan_rrx": "value2", +} + +TRANSLATE_1_OUTPUT = { + "sensor_rx_bytes": "value1", + "wan_rrx": "value2", +} + +TRANSLATE_2_INPUT = [ + "free", + "used", +] + +TRANSLATE_2_OUTPUT = [ + "mem_free", + "mem_used", +] + +TRANSLATE_3_INPUT = [ + "2ghz", + "2ghz2", +] + +TRANSLATE_3_OUTPUT = [ + "2.4GHz", + "2ghz2", +] + + +def test_clean_dict() -> None: + """Test clean_dict method.""" + + assert clean_dict(DICT_TO_CLEAN) == DICT_CLEAN + + +@pytest.mark.parametrize( + ("input", "expected"), + [ + # Case set 0: None as input -> None on output + (None, None), + # Case set 1: Dict structure should stay intact or translated + ({"key1": "value1", "key2": None}, {"key1": "value1", "key2": None}), + (TRANSLATE_0_INPUT, TRANSLATE_0_OUTPUT), + (TRANSLATE_1_INPUT, TRANSLATE_1_OUTPUT), + ({}, {}), + # Case set 2: List structure should stay intact or translated + (["key1", "key2"], ["key1", "key2"]), + (TRANSLATE_2_INPUT, TRANSLATE_2_OUTPUT), + (TRANSLATE_3_INPUT, TRANSLATE_3_OUTPUT), + ([], []), + # Case set 3: Anything else should be simply returned + (123, 123), + ("string", "string"), + (3.1415926535, 3.1415926535), + ], +) +def test_translate(input: Any, expected: Any) -> None: + """Test translate method.""" + + assert translate_to_legacy(input) == expected diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index 929500f0bb7..3ce3246c1d6 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -2,8 +2,9 @@ from datetime import timedelta +from asusrouter import AsusRouterError +from asusrouter.modules.data import AsusData from freezegun.api import FrozenDateTimeFactory -from pyasuswrt.exceptions import AsusWrtError, AsusWrtNotAvailableInfoError import pytest from homeassistant.components import device_tracker, sensor @@ -39,6 +40,7 @@ from .common import ( ROUTER_MAC_ADDR, new_device, ) +from .conftest import make_async_get_data_side_effect from tests.common import MockConfigEntry, async_fire_time_changed @@ -260,8 +262,8 @@ async def test_loadavg_sensors_unaivalable_http( config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA_HTTP, SENSORS_LOAD_AVG) config_entry.add_to_hass(hass) - connect_http.return_value.async_get_loadavg.side_effect = ( - AsusWrtNotAvailableInfoError + connect_http.return_value.async_get_data.side_effect = ( + make_async_get_data_side_effect([AsusData.SYSINFO]) ) # initial devices setup @@ -281,6 +283,7 @@ async def test_temperature_sensors_http_fail( hass: HomeAssistant, connect_http_sens_fail ) -> None: """Test fail creating AsusWRT temperature sensors.""" + _ = connect_http_sens_fail([AsusData.TEMPERATURE]) config_entry, sensor_prefix = _setup_entry( hass, CONFIG_DATA_HTTP, SENSORS_TEMPERATURES ) @@ -347,6 +350,7 @@ async def test_cpu_sensors_http_fail( hass: HomeAssistant, connect_http_sens_fail ) -> None: """Test fail creating AsusWRT cpu sensors.""" + _ = connect_http_sens_fail([AsusData.CPU]) config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA_HTTP, SENSORS_CPU) config_entry.add_to_hass(hass) @@ -367,9 +371,13 @@ async def test_cpu_sensors_http_fail( async def test_cpu_sensors_http( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, connect_http + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + connect_http, + connect_http_sens_detect, ) -> None: """Test creating AsusWRT cpu sensors.""" + connect_http_sens_detect(AsusData.CPU) config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA_HTTP, SENSORS_CPU) config_entry.add_to_hass(hass) @@ -461,7 +469,7 @@ async def test_connect_fail_legacy( @pytest.mark.parametrize( "side_effect", - [AsusWrtError, None], + [AsusRouterError, None], ) async def test_connect_fail_http( hass: HomeAssistant, connect_http, side_effect @@ -476,7 +484,7 @@ async def test_connect_fail_http( config_entry.add_to_hass(hass) connect_http.return_value.async_connect.side_effect = side_effect - connect_http.return_value.is_connected = False + connect_http.return_value.connected = False # initial setup fail await hass.config_entries.async_setup(config_entry.entry_id) @@ -524,6 +532,16 @@ async def test_sensors_polling_fails_http( connect_http_sens_detect, ) -> None: """Test AsusWRT sensors are unavailable when polling fails.""" + # Fail all relevant AsusData types for HTTP sensors + fail_types = [ + AsusData.NETWORK, + AsusData.CPU, + AsusData.SYSINFO, + AsusData.RAM, + AsusData.TEMPERATURE, + AsusData.BOOTTIME, + ] + _ = connect_http_sens_fail(fail_types) await _test_sensors_polling_fails(hass, freezer, CONFIG_DATA_HTTP, SENSORS_ALL_HTTP) From ed6072d46b4d0a9eeec30e8e5243b2c5c36e4ec2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Aug 2025 13:49:43 -0500 Subject: [PATCH 0966/1113] Bump bleak-retry-connector to 4.0.1 (#150515) --- 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 aa0d2076c3f..6887eb4ebeb 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -16,7 +16,7 @@ "quality_scale": "internal", "requirements": [ "bleak==1.0.1", - "bleak-retry-connector==4.0.0", + "bleak-retry-connector==4.0.1", "bluetooth-adapters==2.0.0", "bluetooth-auto-recovery==1.5.2", "bluetooth-data-tools==1.28.2", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b26cd30f0d6..158bf18369f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -20,7 +20,7 @@ audioop-lts==0.2.1 av==13.1.0 awesomeversion==25.5.0 bcrypt==4.3.0 -bleak-retry-connector==4.0.0 +bleak-retry-connector==4.0.1 bleak==1.0.1 bluetooth-adapters==2.0.0 bluetooth-auto-recovery==1.5.2 diff --git a/requirements_all.txt b/requirements_all.txt index a79b4450f5d..40647a6e219 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -628,7 +628,7 @@ bizkaibus==0.1.1 bleak-esphome==3.1.0 # homeassistant.components.bluetooth -bleak-retry-connector==4.0.0 +bleak-retry-connector==4.0.1 # homeassistant.components.bluetooth bleak==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0afbc75649..1b8c663be7e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -562,7 +562,7 @@ bimmer-connected[china]==0.17.2 bleak-esphome==3.1.0 # homeassistant.components.bluetooth -bleak-retry-connector==4.0.0 +bleak-retry-connector==4.0.1 # homeassistant.components.bluetooth bleak==1.0.1 From 6fa7c6cb81f50062caffcbb200a7aefc84952b16 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 12 Aug 2025 20:51:12 +0200 Subject: [PATCH 0967/1113] Add party to Habitica (#149608) --- homeassistant/components/habitica/__init__.py | 61 ++- .../components/habitica/binary_sensor.py | 48 ++- .../components/habitica/coordinator.py | 87 +++-- homeassistant/components/habitica/entity.py | 37 +- homeassistant/components/habitica/icons.json | 21 ++ homeassistant/components/habitica/image.py | 81 +++- .../components/habitica/quality_scale.yaml | 2 +- homeassistant/components/habitica/sensor.py | 135 ++++++- .../components/habitica/strings.json | 42 ++- homeassistant/components/habitica/util.py | 33 +- tests/components/habitica/conftest.py | 4 + tests/components/habitica/fixtures/party.json | 75 ++++ tests/components/habitica/fixtures/user.json | 2 +- .../habitica/fixtures/user_no_party.json | 4 +- .../snapshots/test_binary_sensor.ambr | 49 +++ .../habitica/snapshots/test_sensor.ambr | 354 ++++++++++++++++++ tests/components/habitica/test_init.py | 42 ++- 17 files changed, 1023 insertions(+), 54 deletions(-) create mode 100644 tests/components/habitica/fixtures/party.json diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index 217b5e739d1..514a12d26b7 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -1,19 +1,26 @@ """The habitica integration.""" +from uuid import UUID + from habiticalib import Habitica from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, device_registry as dr 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 CONF_API_USER, DOMAIN, X_CLIENT -from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator +from .coordinator import ( + HabiticaConfigEntry, + HabiticaDataUpdateCoordinator, + HabiticaPartyCoordinator, +) from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) - +HABITICA_KEY: HassKey[dict[UUID, HabiticaPartyCoordinator]] = HassKey(DOMAIN) PLATFORMS = [ Platform.BINARY_SENSOR, @@ -37,6 +44,8 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: HabiticaConfigEntry ) -> bool: """Set up habitica from a config entry.""" + party_added_by_this_entry: UUID | None = None + device_reg = dr.async_get(hass) session = async_get_clientsession( hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True) @@ -54,11 +63,53 @@ async def async_setup_entry( await coordinator.async_config_entry_first_refresh() config_entry.runtime_data = coordinator - await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + party = coordinator.data.user.party.id + if HABITICA_KEY not in hass.data: + hass.data[HABITICA_KEY] = {} + + if party is not None and party not in hass.data[HABITICA_KEY]: + party_coordinator = HabiticaPartyCoordinator(hass, config_entry, api) + await party_coordinator.async_config_entry_first_refresh() + + hass.data[HABITICA_KEY][party] = party_coordinator + party_added_by_this_entry = party + + @callback + def _party_update_listener() -> None: + """On party change, unload coordinator, remove device and reload.""" + nonlocal party, party_added_by_this_entry + party_updated = coordinator.data.user.party.id + + if ( + party is not None and (party not in hass.data[HABITICA_KEY]) + ) or party != party_updated: + if party_added_by_this_entry: + config_entry.async_create_task( + hass, shutdown_party_coordinator(hass, party_added_by_this_entry) + ) + party_added_by_this_entry = None + if party: + identifier = {(DOMAIN, f"{config_entry.unique_id}_{party!s}")} + if device := device_reg.async_get_device(identifiers=identifier): + device_reg.async_update_device( + device.id, remove_config_entry_id=config_entry.entry_id + ) + + hass.config_entries.async_schedule_reload(config_entry.entry_id) + + coordinator.async_add_listener(_party_update_listener) + + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True +async def shutdown_party_coordinator(hass: HomeAssistant, party_added: UUID) -> None: + """Handle party coordinator shutdown.""" + await hass.data[HABITICA_KEY][party_added].async_shutdown() + hass.data[HABITICA_KEY].pop(party_added) + + async def async_unload_entry(hass: HomeAssistant, entry: HabiticaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/habitica/binary_sensor.py b/homeassistant/components/habitica/binary_sensor.py index c6f7ee0fb83..621c659a10c 100644 --- a/homeassistant/components/habitica/binary_sensor.py +++ b/homeassistant/components/habitica/binary_sensor.py @@ -6,18 +6,20 @@ from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum -from habiticalib import UserData +from habiticalib import ContentData, UserData from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import HABITICA_KEY from .const import ASSETS_URL -from .coordinator import HabiticaConfigEntry -from .entity import HabiticaBase +from .coordinator import HabiticaConfigEntry, HabiticaPartyCoordinator +from .entity import HabiticaBase, HabiticaPartyBase PARALLEL_UPDATES = 1 @@ -34,6 +36,7 @@ class HabiticaBinarySensor(StrEnum): """Habitica Entities.""" PENDING_QUEST = "pending_quest" + QUEST_RUNNING = "quest_running" def get_scroll_image_for_pending_quest_invitation(user: UserData) -> str | None: @@ -62,10 +65,21 @@ async def async_setup_entry( coordinator = config_entry.runtime_data - async_add_entities( + entities: list[BinarySensorEntity] = [ HabiticaBinarySensorEntity(coordinator, description) for description in BINARY_SENSOR_DESCRIPTIONS - ) + ] + + if party := coordinator.data.user.party.id: + party_coordinator = hass.data[HABITICA_KEY][party] + entities.append( + HabiticaPartyBinarySensorEntity( + party_coordinator, + config_entry, + coordinator.content, + ) + ) + async_add_entities(entities) class HabiticaBinarySensorEntity(HabiticaBase, BinarySensorEntity): @@ -86,3 +100,27 @@ class HabiticaBinarySensorEntity(HabiticaBase, BinarySensorEntity): ): return f"{ASSETS_URL}{entity_picture}" return None + + +class HabiticaPartyBinarySensorEntity(HabiticaPartyBase, BinarySensorEntity): + """Representation of a Habitica party binary sensor.""" + + entity_description = BinarySensorEntityDescription( + key=HabiticaBinarySensor.QUEST_RUNNING, + translation_key=HabiticaBinarySensor.QUEST_RUNNING, + device_class=BinarySensorDeviceClass.RUNNING, + ) + + def __init__( + self, + coordinator: HabiticaPartyCoordinator, + config_entry: HabiticaConfigEntry, + content: ContentData, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator, config_entry, self.entity_description, content) + + @property + def is_on(self) -> bool | None: + """If the binary sensor is on.""" + return self.coordinator.data.quest.active diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index 0e0a2db8d58..d9376820b16 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +from abc import abstractmethod from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta @@ -13,6 +14,7 @@ from aiohttp import ClientError from habiticalib import ( Avatar, ContentData, + GroupData, Habitica, HabiticaException, NotAuthorizedError, @@ -49,10 +51,11 @@ class HabiticaData: type HabiticaConfigEntry = ConfigEntry[HabiticaDataUpdateCoordinator] -class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): - """Habitica Data Update Coordinator.""" +class HabiticaBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Habitica coordinator base class.""" config_entry: HabiticaConfigEntry + _update_interval: timedelta def __init__( self, hass: HomeAssistant, config_entry: HabiticaConfigEntry, habitica: Habitica @@ -63,7 +66,7 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=timedelta(seconds=60), + update_interval=self._update_interval, request_refresh_debouncer=Debouncer( hass, _LOGGER, @@ -71,8 +74,40 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): immediate=False, ), ) + self.habitica = habitica - self.content: ContentData + + @abstractmethod + async def _update_data(self) -> _DataT: + """Fetch data.""" + + async def _async_update_data(self) -> _DataT: + """Fetch the latest party data.""" + + try: + return await self._update_data() + except TooManyRequestsError: + _LOGGER.debug("Rate limit exceeded, will try again later") + return self.data + except HabiticaException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + + +class HabiticaDataUpdateCoordinator(HabiticaBaseCoordinator[HabiticaData]): + """Habitica Data Update Coordinator.""" + + _update_interval = timedelta(seconds=30) + content: ContentData async def _async_setup(self) -> None: """Set up Habitica integration.""" @@ -106,30 +141,16 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): translation_placeholders={"reason": str(e)}, ) from e - async def _async_update_data(self) -> HabiticaData: - try: - user = (await self.habitica.get_user()).data - tasks = (await self.habitica.get_tasks()).data - completed_todos = ( - await self.habitica.get_tasks(TaskFilter.COMPLETED_TODOS) - ).data - except TooManyRequestsError: - _LOGGER.debug("Rate limit exceeded, will try again later") - return self.data - except HabiticaException as e: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e.error.message)}, - ) from e - except ClientError as e: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e)}, - ) from e - else: - return HabiticaData(user=user, tasks=tasks + completed_todos) + async def _update_data(self) -> HabiticaData: + """Fetch the latest data.""" + + user = (await self.habitica.get_user()).data + tasks = (await self.habitica.get_tasks()).data + completed_todos = ( + await self.habitica.get_tasks(TaskFilter.COMPLETED_TODOS) + ).data + + return HabiticaData(user=user, tasks=tasks + completed_todos) async def execute(self, func: Callable[[Habitica], Any]) -> None: """Execute an API call.""" @@ -169,3 +190,13 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): await self.habitica.generate_avatar(fp=png, avatar=avatar, fmt="PNG") return png.getvalue() + + +class HabiticaPartyCoordinator(HabiticaBaseCoordinator[GroupData]): + """Habitica Party Coordinator.""" + + _update_interval = timedelta(minutes=15) + + async def _update_data(self) -> GroupData: + """Fetch the latest party data.""" + return (await self.habitica.get_group()).data diff --git a/homeassistant/components/habitica/entity.py b/homeassistant/components/habitica/entity.py index 6d320f93517..fa227fec334 100644 --- a/homeassistant/components/habitica/entity.py +++ b/homeassistant/components/habitica/entity.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import TYPE_CHECKING +from habiticalib import ContentData from yarl import URL from homeassistant.const import CONF_URL @@ -12,7 +13,11 @@ from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER, NAME -from .coordinator import HabiticaDataUpdateCoordinator +from .coordinator import ( + HabiticaConfigEntry, + HabiticaDataUpdateCoordinator, + HabiticaPartyCoordinator, +) class HabiticaBase(CoordinatorEntity[HabiticaDataUpdateCoordinator]): @@ -45,3 +50,33 @@ class HabiticaBase(CoordinatorEntity[HabiticaDataUpdateCoordinator]): ), identifiers={(DOMAIN, coordinator.config_entry.unique_id)}, ) + + +class HabiticaPartyBase(CoordinatorEntity[HabiticaPartyCoordinator]): + """Base Habitica entity representing a party.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: HabiticaPartyCoordinator, + config_entry: HabiticaConfigEntry, + entity_description: EntityDescription, + content: ContentData, + ) -> None: + """Initialize a Habitica party entity.""" + super().__init__(coordinator) + if TYPE_CHECKING: + assert config_entry.unique_id + unique_id = f"{config_entry.unique_id}_{coordinator.data.id!s}" + self.entity_description = entity_description + self._attr_unique_id = f"{unique_id}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer=MANUFACTURER, + model=NAME, + name=coordinator.data.summary, + identifiers={(DOMAIN, unique_id)}, + via_device=(DOMAIN, config_entry.unique_id), + ) + self.content = content diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index be25bebe779..0b5d4aaa682 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -156,6 +156,24 @@ }, "pending_quest_items": { "default": "mdi:sack" + }, + "group_leader": { + "default": "mdi:shield-crown" + }, + "quest": { + "default": "mdi:script-text-outline" + }, + "boss": { + "default": "mdi:emoticon-devil" + }, + "boss_hp": { + "default": "mdi:heart" + }, + "boss_hp_remaining": { + "default": "mdi:heart" + }, + "collected_items": { + "default": "mdi:sack" } }, "switch": { @@ -172,6 +190,9 @@ "state": { "on": "mdi:script-text-outline" } + }, + "quest_running": { + "default": "mdi:script-text-play" } } }, diff --git a/homeassistant/components/habitica/image.py b/homeassistant/components/habitica/image.py index 1669f124bc7..f064074ea0a 100644 --- a/homeassistant/components/habitica/image.py +++ b/homeassistant/components/habitica/image.py @@ -4,15 +4,21 @@ from __future__ import annotations from enum import StrEnum -from habiticalib import Avatar, extract_avatar +from habiticalib import Avatar, ContentData, extract_avatar -from homeassistant.components.image import ImageEntity, ImageEntityDescription +from homeassistant.components.image import Image, 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 HabiticaConfigEntry, HabiticaDataUpdateCoordinator -from .entity import HabiticaBase +from . import HABITICA_KEY +from .const import ASSETS_URL +from .coordinator import ( + HabiticaConfigEntry, + HabiticaDataUpdateCoordinator, + HabiticaPartyCoordinator, +) +from .entity import HabiticaBase, HabiticaPartyBase PARALLEL_UPDATES = 1 @@ -21,6 +27,7 @@ class HabiticaImageEntity(StrEnum): """Image entities.""" AVATAR = "avatar" + QUEST_IMAGE = "quest_image" async def async_setup_entry( @@ -31,8 +38,17 @@ async def async_setup_entry( """Set up the habitica image platform.""" coordinator = config_entry.runtime_data + entities: list[ImageEntity] = [HabiticaImage(hass, coordinator)] - async_add_entities([HabiticaImage(hass, coordinator)]) + if party := coordinator.data.user.party.id: + party_coordinator = hass.data[HABITICA_KEY][party] + entities.append( + HabiticaPartyImage( + hass, party_coordinator, config_entry, coordinator.content + ) + ) + + async_add_entities(entities) class HabiticaImage(HabiticaBase, ImageEntity): @@ -72,3 +88,58 @@ class HabiticaImage(HabiticaBase, ImageEntity): if not self._cache and self._avatar: self._cache = await self.coordinator.generate_avatar(self._avatar) return self._cache + + +class HabiticaPartyImage(HabiticaPartyBase, ImageEntity): + """A Habitica image entity of a party.""" + + entity_description = ImageEntityDescription( + key=HabiticaImageEntity.QUEST_IMAGE, + translation_key=HabiticaImageEntity.QUEST_IMAGE, + ) + _attr_content_type = "image/png" + + def __init__( + self, + hass: HomeAssistant, + coordinator: HabiticaPartyCoordinator, + config_entry: HabiticaConfigEntry, + content: ContentData, + ) -> None: + """Initialize the image entity.""" + super().__init__(coordinator, config_entry, self.entity_description, content) + ImageEntity.__init__(self, hass) + + self._attr_image_url = self.image_url + self._attr_image_last_updated = dt_util.utcnow() + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + if self.image_url != self._attr_image_url: + self._attr_image_url = self.image_url + self._cached_image = None + self._attr_image_last_updated = dt_util.utcnow() + + super()._handle_coordinator_update() + + @property + def image_url(self) -> str | None: + """Return URL of image.""" + return ( + f"{ASSETS_URL}quest_{key}.png" + if (key := self.coordinator.data.quest.key) + else None + ) + + async def _async_load_image_from_url(self, url: str) -> Image | None: + """Load an image by url. + + AWS sometimes returns 'application/octet-stream' as content-type + """ + if response := await self._fetch_url(url): + return Image( + content=response.content, + content_type=self._attr_content_type, + ) + return None diff --git a/homeassistant/components/habitica/quality_scale.yaml b/homeassistant/components/habitica/quality_scale.yaml index 1752e67cf46..c5131b81a4d 100644 --- a/homeassistant/components/habitica/quality_scale.yaml +++ b/homeassistant/components/habitica/quality_scale.yaml @@ -72,7 +72,7 @@ rules: comment: Used to inform of deprecated entities and actions. stale-devices: status: done - comment: Not applicable. Only one device per config entry. Removed together with the config entry. + comment: Party device is remove if stale. # Platinum async-dependency: done diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index 6d077495c4f..7a84d589bfb 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -8,7 +8,7 @@ from enum import StrEnum import logging from typing import Any -from habiticalib import ContentData, HabiticaClass, TaskData, UserData, ha +from habiticalib import ContentData, GroupData, HabiticaClass, TaskData, UserData, ha from homeassistant.components.sensor import ( SensorDeviceClass, @@ -20,15 +20,19 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util +from . import HABITICA_KEY from .const import ASSETS_URL from .coordinator import HabiticaConfigEntry -from .entity import HabiticaBase +from .entity import HabiticaBase, HabiticaPartyBase from .util import ( + collected_quest_items, get_attribute_points, get_attributes_total, inventory_list, pending_damage, pending_quest_items, + quest_attributes, + quest_boss, ) _LOGGER = logging.getLogger(__name__) @@ -55,6 +59,17 @@ class HabiticaSensorEntityDescription(SensorEntityDescription): entity_picture: str | None = None +@dataclass(kw_only=True, frozen=True) +class HabiticaPartySensorEntityDescription(SensorEntityDescription): + """Habitica Party Sensor Description.""" + + value_fn: Callable[[GroupData, ContentData], StateType] + entity_picture: Callable[[GroupData], str | None] | str | None = None + attributes_fn: Callable[[GroupData, ContentData], dict[str, Any] | None] | None = ( + None + ) + + @dataclass(kw_only=True, frozen=True) class HabiticaTaskSensorEntityDescription(SensorEntityDescription): """Habitica Task Sensor Description.""" @@ -89,6 +104,13 @@ class HabiticaSensorEntity(StrEnum): QUEST_SCROLLS = "quest_scrolls" PENDING_DAMAGE = "pending_damage" PENDING_QUEST_ITEMS = "pending_quest_items" + MEMBER_COUNT = "member_count" + GROUP_LEADER = "group_leader" + QUEST = "quest" + BOSS = "boss" + BOSS_HP = "boss_hp" + BOSS_HP_REMAINING = "boss_hp_remaining" + COLLECTED_ITEMS = "collected_items" SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( @@ -262,6 +284,67 @@ SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( ) +SENSOR_DESCRIPTIONS_PARTY: tuple[HabiticaPartySensorEntityDescription, ...] = ( + HabiticaPartySensorEntityDescription( + key=HabiticaSensorEntity.MEMBER_COUNT, + translation_key=HabiticaSensorEntity.MEMBER_COUNT, + value_fn=lambda party, _: party.memberCount, + entity_picture=ha.PARTY, + ), + HabiticaPartySensorEntityDescription( + key=HabiticaSensorEntity.GROUP_LEADER, + translation_key=HabiticaSensorEntity.GROUP_LEADER, + value_fn=lambda party, _: party.leader.profile.name, + ), + HabiticaPartySensorEntityDescription( + key=HabiticaSensorEntity.QUEST, + translation_key=HabiticaSensorEntity.QUEST, + value_fn=lambda p, c: c.quests[p.quest.key].text if p.quest.key else None, + attributes_fn=quest_attributes, + entity_picture=( + lambda party: f"inventory_quest_scroll_{party.quest.key}.png" + if party.quest.key + else None + ), + ), + HabiticaPartySensorEntityDescription( + key=HabiticaSensorEntity.BOSS, + translation_key=HabiticaSensorEntity.BOSS, + value_fn=lambda p, c: boss.name if (boss := quest_boss(p, c)) else None, + ), + HabiticaPartySensorEntityDescription( + key=HabiticaSensorEntity.BOSS_HP, + translation_key=HabiticaSensorEntity.BOSS_HP, + value_fn=lambda p, c: boss.hp if (boss := quest_boss(p, c)) else None, + entity_picture=ha.HP, + suggested_display_precision=0, + ), + HabiticaPartySensorEntityDescription( + key=HabiticaSensorEntity.BOSS_HP_REMAINING, + translation_key=HabiticaSensorEntity.BOSS_HP_REMAINING, + value_fn=lambda p, _: p.quest.progress.hp, + entity_picture=ha.HP, + suggested_display_precision=2, + ), + HabiticaPartySensorEntityDescription( + key=HabiticaSensorEntity.COLLECTED_ITEMS, + translation_key=HabiticaSensorEntity.COLLECTED_ITEMS, + value_fn=( + lambda p, _: sum(n for n in p.quest.progress.collect.values()) + if p.quest.progress.collect + else None + ), + attributes_fn=collected_quest_items, + entity_picture=( + lambda p: f"quest_{p.quest.key}_{k}.png" + if p.quest.progress.collect + and (k := next(iter(p.quest.progress.collect), None)) + else None + ), + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: HabiticaConfigEntry, @@ -275,6 +358,18 @@ async def async_setup_entry( HabiticaSensor(coordinator, description) for description in SENSOR_DESCRIPTIONS ) + if party := coordinator.data.user.party.id: + party_coordinator = hass.data[HABITICA_KEY][party] + async_add_entities( + HabiticaPartySensor( + party_coordinator, + config_entry, + description, + coordinator.content, + ) + for description in SENSOR_DESCRIPTIONS_PARTY + ) + class HabiticaSensor(HabiticaBase, SensorEntity): """A generic Habitica sensor.""" @@ -317,3 +412,39 @@ class HabiticaSensor(HabiticaBase, SensorEntity): ) return None + + +class HabiticaPartySensor(HabiticaPartyBase, SensorEntity): + """Habitica party sensor.""" + + entity_description: HabiticaPartySensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the state of the device.""" + + return self.entity_description.value_fn(self.coordinator.data, self.content) + + @property + def entity_picture(self) -> str | None: + """Return the entity picture to use in the frontend, if any.""" + pic = self.entity_description.entity_picture + + entity_picture = ( + pic if isinstance(pic, str) or pic is None else pic(self.coordinator.data) + ) + + return ( + None + if not entity_picture + else entity_picture + if entity_picture.startswith("data:image") + else f"{ASSETS_URL}{entity_picture}" + ) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return entity specific state attributes.""" + if func := self.entity_description.attributes_fn: + return func(self.coordinator.data, self.content) + return None diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 6f0b3dc35cd..1d62b242149 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -7,6 +7,7 @@ "unit_health_points": "HP", "unit_mana_points": "MP", "unit_experience_points": "XP", + "unit_items": "items", "config_entry_description": "Select the Habitica account to update a task.", "task_description": "The name (or task ID) of the task you want to update.", "rename_name": "Rename", @@ -63,7 +64,8 @@ "repeat_weekly_options_name": "Weekly repeat days", "repeat_weekly_options_description": "Options related to weekly repetition, applicable when the repetition interval is set to weekly.", "repeat_monthly_options_name": "Monthly repeat day", - "repeat_monthly_options_description": "Options related to monthly repetition, applicable when the repetition interval is set to monthly." + "repeat_monthly_options_description": "Options related to monthly repetition, applicable when the repetition interval is set to monthly.", + "quest_name": "Quest" }, "config": { "abort": { @@ -173,6 +175,9 @@ "binary_sensor": { "pending_quest": { "name": "Pending quest invitation" + }, + "quest_running": { + "name": "Quest status" } }, "button": { @@ -251,6 +256,9 @@ "image": { "avatar": { "name": "Avatar" + }, + "quest_image": { + "name": "[%key:component::habitica::common::quest_name%]" } }, "sensor": { @@ -420,7 +428,37 @@ }, "pending_quest_items": { "name": "Pending quest items", - "unit_of_measurement": "items" + "unit_of_measurement": "[%key:component::habitica::common::unit_items%]" + }, + "member_count": { + "name": "Member count", + "unit_of_measurement": "members" + }, + "group_leader": { + "name": "Group leader" + }, + "quest": { + "name": "[%key:component::habitica::common::quest_name%]", + "state_attributes": { + "quest_details": { + "name": "Quest details" + } + } + }, + "boss": { + "name": "Quest boss" + }, + "boss_hp": { + "name": "Boss health", + "unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]" + }, + "boss_hp_remaining": { + "name": "Boss health remaining", + "unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]" + }, + "collected_items": { + "name": "Collected quest items", + "unit_of_measurement": "[%key:component::habitica::common::unit_items%]" } }, "switch": { diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index 4f948b9b4d2..8c2148192a3 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, Literal +from typing import TYPE_CHECKING, Any, Literal from dateutil.rrule import ( DAILY, @@ -21,7 +21,7 @@ from dateutil.rrule import ( YEARLY, rrule, ) -from habiticalib import ContentData, Frequency, TaskData, UserData +from habiticalib import ContentData, Frequency, GroupData, QuestBoss, TaskData, UserData from homeassistant.util import dt as dt_util @@ -184,3 +184,32 @@ def pending_damage(user: UserData, content: ContentData) -> float | None: and content.quests[user.party.quest.key].boss is not None else None ) + + +def quest_attributes(party: GroupData, content: ContentData) -> dict[str, Any]: + """Quest description.""" + return { + "quest_details": content.quests[party.quest.key].notes + if party.quest.key + else None, + "quest_participants": f"{sum(x is True for x in party.quest.members.values())} / {party.memberCount}", + } + + +def quest_boss(party: GroupData, content: ContentData) -> QuestBoss | None: + """Quest boss.""" + + return content.quests[party.quest.key].boss if party.quest.key else None + + +def collected_quest_items(party: GroupData, content: ContentData) -> dict[str, Any]: + """List collected quest items.""" + + return ( + { + collect[k].text: f"{v} / {collect[k].count}" + for k, v in party.quest.progress.collect.items() + } + if party.quest.key and (collect := content.quests[party.quest.key].collect) + else {} + ) diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index 80e09d823cc..331d2ccf36a 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -10,6 +10,7 @@ from habiticalib import ( HabiticaContentResponse, HabiticaErrorResponse, HabiticaGroupMembersResponse, + HabiticaGroupsResponse, HabiticaLoginResponse, HabiticaQuestResponse, HabiticaResponse, @@ -155,6 +156,9 @@ async def mock_habiticalib(hass: HomeAssistant) -> AsyncGenerator[AsyncMock]: client.create_task.return_value = HabiticaTaskResponse.from_json( await async_load_fixture(hass, "task.json", DOMAIN) ) + client.get_group.return_value = HabiticaGroupsResponse.from_json( + await async_load_fixture(hass, "party.json", DOMAIN) + ) yield client diff --git a/tests/components/habitica/fixtures/party.json b/tests/components/habitica/fixtures/party.json new file mode 100644 index 00000000000..18e7936ca85 --- /dev/null +++ b/tests/components/habitica/fixtures/party.json @@ -0,0 +1,75 @@ +{ + "success": true, + "data": { + "leaderOnly": { + "challenges": false, + "getGems": false + }, + "quest": { + "progress": { + "collect": { + "soapBars": 10 + } + }, + "key": "atom1", + "active": true, + "leader": "d69833ef-4542-4259-ba50-9b4a1a841bcf", + "members": { + "d69833ef-4542-4259-ba50-9b4a1a841bcf": true + }, + "extra": {} + }, + "tasksOrder": { + "habits": [], + "dailys": [], + "todos": [], + "rewards": [] + }, + "purchased": { + "plan": { + "consecutive": { + "count": 0, + "offset": 0, + "gemCapExtra": 0, + "trinkets": 0 + }, + "quantity": 1, + "extraMonths": 0, + "gemsBought": 0, + "cumulativeCount": 0, + "mysteryItems": [] + } + }, + "cron": {}, + "_id": "1e87097c-4c03-4f8c-a475-67cc7da7f409", + "name": "test-user's Party", + "type": "party", + "privacy": "private", + "chat": [], + "memberCount": 2, + "challengeCount": 0, + "balance": 0, + "managers": {}, + "categories": [], + "leader": { + "auth": { + "local": { + "username": "test-username" + } + }, + "flags": { + "verifiedUsername": true + }, + "profile": { + "name": "test-user" + }, + "_id": "af36e2a8-7927-4dec-a258-400ade7f0ae3", + "id": "af36e2a8-7927-4dec-a258-400ade7f0ae3" + }, + "summary": "test-user's Party", + "id": "1e87097c-4c03-4f8c-a475-67cc7da7f409" + }, + "notifications": [], + "userV": 0, + "appVersion": "5.38.0" +} diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index d2f0091b6dd..28faec64dc9 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -76,7 +76,7 @@ "RSVPNeeded": true, "key": "dustbunnies" }, - "_id": "94cd398c-2240-4320-956e-6d345cf2c0de" + "_id": "1e87097c-4c03-4f8c-a475-67cc7da7f409" }, "tags": [ { diff --git a/tests/components/habitica/fixtures/user_no_party.json b/tests/components/habitica/fixtures/user_no_party.json index 1c58dde6f50..bd447b1af67 100644 --- a/tests/components/habitica/fixtures/user_no_party.json +++ b/tests/components/habitica/fixtures/user_no_party.json @@ -55,7 +55,9 @@ "e97659e0-2c42-4599-a7bb-00282adc410d", "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", "f2c85972-1a19-4426-bc6d-ce3337b9d99f", - "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1" + "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1", + "6e53f1f5-a315-4edd-984d-8d762e4a08ef", + "369afeed-61e3-4bf7-9747-66e05807134c" ], "habits": ["1d147de6-5c02-4740-8e2f-71d3015a37f4"] }, diff --git a/tests/components/habitica/snapshots/test_binary_sensor.ambr b/tests/components/habitica/snapshots/test_binary_sensor.ambr index 247063f2ae8..64dbc160a1b 100644 --- a/tests/components/habitica/snapshots/test_binary_sensor.ambr +++ b/tests/components/habitica/snapshots/test_binary_sensor.ambr @@ -48,3 +48,52 @@ 'state': 'on', }) # --- +# name: test_binary_sensors[binary_sensor.test_user_s_party_quest_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_user_s_party_quest_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': 'Quest status', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_quest_running', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_user_s_party_quest_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': "test-user's Party Quest status", + }), + 'context': , + 'entity_id': 'binary_sensor.test_user_s_party_quest_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index 30c0f9d66eb..89d6936f111 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -1064,6 +1064,360 @@ 'state': '2', }) # --- +# name: test_sensors[sensor.test_user_s_party_boss_health-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_user_s_party_boss_health', + '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': 'Boss health', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_boss_hp', + 'unit_of_measurement': 'HP', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_boss_health-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiNGNzRFNTIiIGQ9Ik0yIDQuNUw2LjE2NyAyIDEyIDUuMTY3IDE3LjgzMyAyIDIyIDQuNVYxMmwtNC4xNjcgNS44MzNMMTIgMjJsLTUuODMzLTQuMTY3TDIgMTJ6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGNjE2NSIgZD0iTTcuMzMzIDE2LjY2N0wzLjY2NyAxMS41VjUuNDE3bDIuNS0xLjVMMTIgNy4wODNsNS44MzMtMy4xNjYgMi41IDEuNVYxMS41bC0zLjY2NiA1LjE2N0wxMiAxOS45MTd6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M2w0LjY2NyAyLjU4NEwxMiAxOS45MTd6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNsLTQuNjY3IDIuNTg0TDEyIDE5LjkxN3oiIG9wYWNpdHk9Ii4zNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik03LjMzMyAxNi42NjdMMy42NjcgMTEuNSAxMiAxNC4wODN6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQjUyNDI4IiBkPSJNMTYuNjY3IDE2LjY2N2wzLjY2Ni01LjE2N0wxMiAxNC4wODN6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNsNS44MzMtMTAuMTY2IDIuNSAxLjVWMTEuNXoiIG9wYWNpdHk9Ii4zNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNMNi4xNjcgMy45MTdsLTIuNSAxLjVWMTEuNXoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M0w2LjE2NyAzLjkxNyAxMiA3LjA4M3oiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M2w1LjgzMy0xMC4xNjZMMTIgNy4wODN6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjRkZGIiBkPSJNOS4xNjcgMTQuODMzbC0zLTQuMTY2VjYuODMzaC4wODNMMTIgOS45MTdsNS43NS0zLjA4NGguMDgzdjMuODM0bC0zIDQuMTY2TDEyIDE2LjkxN3oiIG9wYWNpdHk9Ii41Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', + 'friendly_name': "test-user's Party Boss health", + 'unit_of_measurement': 'HP', + }), + 'context': , + 'entity_id': 'sensor.test_user_s_party_boss_health', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_boss_health_remaining-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_user_s_party_boss_health_remaining', + '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': 'Boss health remaining', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_boss_hp_remaining', + 'unit_of_measurement': 'HP', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_boss_health_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiNGNzRFNTIiIGQ9Ik0yIDQuNUw2LjE2NyAyIDEyIDUuMTY3IDE3LjgzMyAyIDIyIDQuNVYxMmwtNC4xNjcgNS44MzNMMTIgMjJsLTUuODMzLTQuMTY3TDIgMTJ6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGNjE2NSIgZD0iTTcuMzMzIDE2LjY2N0wzLjY2NyAxMS41VjUuNDE3bDIuNS0xLjVMMTIgNy4wODNsNS44MzMtMy4xNjYgMi41IDEuNVYxMS41bC0zLjY2NiA1LjE2N0wxMiAxOS45MTd6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M2w0LjY2NyAyLjU4NEwxMiAxOS45MTd6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNsLTQuNjY3IDIuNTg0TDEyIDE5LjkxN3oiIG9wYWNpdHk9Ii4zNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik03LjMzMyAxNi42NjdMMy42NjcgMTEuNSAxMiAxNC4wODN6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQjUyNDI4IiBkPSJNMTYuNjY3IDE2LjY2N2wzLjY2Ni01LjE2N0wxMiAxNC4wODN6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNsNS44MzMtMTAuMTY2IDIuNSAxLjVWMTEuNXoiIG9wYWNpdHk9Ii4zNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNMNi4xNjcgMy45MTdsLTIuNSAxLjVWMTEuNXoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M0w2LjE2NyAzLjkxNyAxMiA3LjA4M3oiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M2w1LjgzMy0xMC4xNjZMMTIgNy4wODN6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjRkZGIiBkPSJNOS4xNjcgMTQuODMzbC0zLTQuMTY2VjYuODMzaC4wODNMMTIgOS45MTdsNS43NS0zLjA4NGguMDgzdjMuODM0bC0zIDQuMTY2TDEyIDE2LjkxN3oiIG9wYWNpdHk9Ii41Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', + 'friendly_name': "test-user's Party Boss health remaining", + 'unit_of_measurement': 'HP', + }), + 'context': , + 'entity_id': 'sensor.test_user_s_party_boss_health_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_collected_quest_items-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_user_s_party_collected_quest_items', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Collected quest items', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_collected_items', + 'unit_of_measurement': 'items', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_collected_quest_items-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Seifenstücke': '10 / 20', + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/quest_atom1_soapBars.png', + 'friendly_name': "test-user's Party Collected quest items", + 'unit_of_measurement': 'items', + }), + 'context': , + 'entity_id': 'sensor.test_user_s_party_collected_quest_items', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_group_leader-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_user_s_party_group_leader', + 'has_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 leader', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_group_leader', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_user_s_party_group_leader-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': "test-user's Party Group leader", + }), + 'context': , + 'entity_id': 'sensor.test_user_s_party_group_leader', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'test-user', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_member_count-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_user_s_party_member_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': 'Member count', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_member_count', + 'unit_of_measurement': 'members', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_member_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii02IC02IDQ2IDUwIj48dGl0bGU+QnJvbnplX1NtYWxsPC90aXRsZT48cGF0aCBkPSJNMjAsMzYuMjhDNy4xOCwzMC42MSw1LjYsMjQuNjksNS41LDEwLjQyTDIwLDMuNzVsMTQuNSw2LjY3QzM0LjQsMjQuNjksMzIuODIsMzAuNjEsMjAsMzYuMjhaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMyAtMSkiIGZpbGw9IiNlYThjMzEiPjwvcGF0aD48cGF0aCBkPSJNMjAsNi41TDMyLDEyYy0wLjE1LDExLjU2LTEuNTEsMTYuNjItMTIsMjEuNTFDOS41MywyOC42NCw4LjE3LDIzLjU4LDgsMTJMMjAsNi41TTIwLDFMMyw4LjgyQzMsMjQuNDcsNC4xMywzMi4yOSwyMCwzOSwzNS44NywzMi4yOSwzNywyNC40NywzNyw4LjgyTDIwLDFoMFoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0zIC0xKSIgZmlsbD0iI2IzNjIxMyI+PC9wYXRoPjxwYXRoIGQ9Ik0yMCw0LjNsMTQsNi40NGMtMC4xMiwxMy43Mi0xLjcyLDE5LjUxLTE0LDI1QzcuNzMsMzAuMjUsNi4xMiwyNC40Niw2LDEwLjc0TDIwLDQuM00yMCwxTDMsOC44MkMzLDI0LjQ3LDQuMTMsMzIuMjksMjAsMzksMzUuODcsMzIuMjksMzcsMjQuNDcsMzcsOC44MkwyMCwxaDBaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMyAtMSkiIGZpbGw9IiNkNzdhMjAiPjwvcGF0aD48L3N2Zz4=', + 'friendly_name': "test-user's Party Member count", + 'unit_of_measurement': 'members', + }), + 'context': , + 'entity_id': 'sensor.test_user_s_party_member_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_quest-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_user_s_party_quest', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Quest', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_quest', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_user_s_party_quest-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/inventory_quest_scroll_atom1.png', + 'friendly_name': "test-user's Party Quest", + 'quest_details': 'Du erreichst die Ufer des Waschbeckensees für eine wohlverdiente Auszeit ... Aber der See ist verschmutzt mit nicht abgespültem Geschirr! Wie ist das passiert? Wie auch immer, Du kannst den See jedenfalls nicht in diesem Zustand lassen. Es gibt nur eine Sache die Du tun kannst: Abspülen und den Ferienort retten! Dazu musst Du aber Seife für den Abwasch finden. Viel Seife ...', + 'quest_participants': '1 / 2', + }), + 'context': , + 'entity_id': 'sensor.test_user_s_party_quest', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Angriff des Banalen, Teil 1: Abwasch-Katastrophe!', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_quest_boss-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_user_s_party_quest_boss', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Quest boss', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_boss', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_user_s_party_quest_boss-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': "test-user's Party Quest boss", + }), + 'context': , + 'entity_id': 'sensor.test_user_s_party_quest_boss', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensors[sensor.test_user_saddles-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/habitica/test_init.py b/tests/components/habitica/test_init.py index e904ccc890d..92be6cbe881 100644 --- a/tests/components/habitica/test_init.py +++ b/tests/components/habitica/test_init.py @@ -6,11 +6,13 @@ from unittest.mock import AsyncMock from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory +from habiticalib import HabiticaUserResponse import pytest from homeassistant.components.habitica.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 .conftest import ( ERROR_BAD_REQUEST, @@ -19,7 +21,7 @@ from .conftest import ( ERROR_TOO_MANY_REQUESTS, ) -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, async_load_fixture @pytest.mark.usefixtures("habitica") @@ -128,3 +130,41 @@ async def test_coordinator_rate_limited( await hass.async_block_till_done() assert "Rate limit exceeded, will try again later" in caplog.text + + +async def test_remove_party_and_reload( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + freezer: FrozenDateTimeFactory, + device_registry: dr.DeviceRegistry, +) -> None: + """Test we leave the party and device is removed.""" + group_id = "1e87097c-4c03-4f8c-a475-67cc7da7f409" + 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 ( + device_registry.async_get_device( + {(DOMAIN, f"{config_entry.unique_id}_{group_id}")} + ) + is not None + ) + + habitica.get_user.return_value = HabiticaUserResponse.from_json( + await async_load_fixture(hass, "user_no_party.json", DOMAIN) + ) + + freezer.tick(datetime.timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + device_registry.async_get_device( + {(DOMAIN, f"{config_entry.unique_id}_{group_id}")} + ) + is None + ) From 452322e97180294e7aebb12ac8ddf66fd43f464b Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 12 Aug 2025 21:16:43 +0200 Subject: [PATCH 0968/1113] Modbus: Do not remove non-duplicate error log. (#150511) --- homeassistant/components/modbus/modbus.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 9e0ba63b4a0..1bd17f17b36 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -263,6 +263,7 @@ class ModbusHub: self._config_delay = client_config[CONF_DELAY] self._pb_request: dict[str, RunEntry] = {} self._connect_task: asyncio.Task + self._last_log_error: str = "" self._pb_class = { SERIAL: AsyncModbusSerialClient, TCP: AsyncModbusTcpClient, @@ -303,13 +304,12 @@ class ModbusHub: else: self._msg_wait = 0 - def _log_error(self, text: str, error_state: bool = True) -> None: + def _log_error(self, text: str) -> None: + if text == self._last_log_error: + return + self._last_log_error = text log_text = f"Pymodbus: {self.name}: {text}" - if self._in_error: - _LOGGER.debug(log_text) - else: - _LOGGER.error(log_text) - self._in_error = error_state + _LOGGER.error(log_text) async def async_pb_connect(self) -> None: """Connect to device, async.""" @@ -318,7 +318,7 @@ class ModbusHub: await self._client.connect() # type: ignore[union-attr] except ModbusException as exception_error: err = f"{self.name} connect failed, retry in pymodbus ({exception_error!s})" - self._log_error(err, error_state=False) + self._log_error(err) return message = f"modbus {self.name} communication open" _LOGGER.info(message) @@ -328,7 +328,7 @@ class ModbusHub: try: self._client = self._pb_class[self._config_type](**self._pb_params) except ModbusException as exception_error: - self._log_error(str(exception_error), error_state=False) + self._log_error(str(exception_error)) return False for entry in PB_CALL: From 83f911e4ffa5b5fee08d5709d63926871335fcf5 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Tue, 12 Aug 2025 22:53:56 +0300 Subject: [PATCH 0969/1113] Bump aiowebostv to 0.7.5 (#150514) --- homeassistant/components/webostv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index c3c3e9a564f..f8201fe3bef 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.4"], + "requirements": ["aiowebostv==0.7.5"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/requirements_all.txt b/requirements_all.txt index 40647a6e219..9075b36fe89 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -438,7 +438,7 @@ aiowatttime==0.1.1 aiowebdav2==0.4.6 # homeassistant.components.webostv -aiowebostv==0.7.4 +aiowebostv==0.7.5 # homeassistant.components.withings aiowithings==3.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1b8c663be7e..0e5c3af5243 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -420,7 +420,7 @@ aiowatttime==0.1.1 aiowebdav2==0.4.6 # homeassistant.components.webostv -aiowebostv==0.7.4 +aiowebostv==0.7.5 # homeassistant.components.withings aiowithings==3.1.6 From d19e410ea882ae3d4af541bc8ab3e1dc56e5fb59 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Aug 2025 15:07:25 -0500 Subject: [PATCH 0970/1113] Bump aiodhcpwatcher to 1.2.1 (#150519) --- 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 599e5ecae5b..32abe0684f7 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -15,7 +15,7 @@ ], "quality_scale": "internal", "requirements": [ - "aiodhcpwatcher==1.2.0", + "aiodhcpwatcher==1.2.1", "aiodiscover==2.7.1", "cached-ipaddress==0.10.0" ] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 158bf18369f..7b853e57565 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,6 +1,6 @@ # Automatically generated by gen_requirements_all.py, do not edit -aiodhcpwatcher==1.2.0 +aiodhcpwatcher==1.2.1 aiodiscover==2.7.1 aiodns==3.5.0 aiohasupervisor==0.3.1 diff --git a/requirements_all.txt b/requirements_all.txt index 9075b36fe89..85648dce34a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -220,7 +220,7 @@ aiobotocore==2.21.1 aiocomelit==0.12.3 # homeassistant.components.dhcp -aiodhcpwatcher==1.2.0 +aiodhcpwatcher==1.2.1 # homeassistant.components.dhcp aiodiscover==2.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e5c3af5243..f1cee9ad504 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -208,7 +208,7 @@ aiobotocore==2.21.1 aiocomelit==0.12.3 # homeassistant.components.dhcp -aiodhcpwatcher==1.2.0 +aiodhcpwatcher==1.2.1 # homeassistant.components.dhcp aiodiscover==2.7.1 From b7853ea9bd6964b9b7653c441e973208f1a6ca9e Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Tue, 12 Aug 2025 17:18:59 -0400 Subject: [PATCH 0971/1113] Fix Sonos CI Issue (#150518) Co-authored-by: Joost Lekkerkerker --- tests/components/sonos/conftest.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 7ae3af8f748..c6230eb9344 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -169,7 +169,7 @@ async def async_autosetup_sonos(async_setup_sonos): @pytest.fixture def async_setup_sonos( - hass: HomeAssistant, config_entry: MockConfigEntry, fire_zgs_event + hass: HomeAssistant, config_entry: MockConfigEntry, fire_zgs_event, alarm_event ) -> Callable[[], Coroutine[Any, Any, None]]: """Return a coroutine to set up a Sonos integration instance on demand.""" @@ -177,6 +177,7 @@ def async_setup_sonos( config_entry.add_to_hass(hass) sonos_alarms = Alarms() sonos_alarms.last_alarm_list_version = "RINCON_test:0" + alarm_event.variables["alarm_list_version"] = "RINCON_test:0" assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) await fire_zgs_event() @@ -278,6 +279,7 @@ class SoCoMockFactory: mock_soco.music_source_from_uri = SoCo.music_source_from_uri mock_soco.get_sonos_playlists.return_value = self.sonos_playlists mock_soco.get_queue.return_value = self.sonos_queue + mock_soco._player_name = name my_speaker_info = self.speaker_info.copy() my_speaker_info["zone_name"] = name my_speaker_info["uid"] = mock_soco.uid From d9ca253c6c29f7b91e9c45b1fd6c3dad65b17d9e Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 13 Aug 2025 09:45:54 +0200 Subject: [PATCH 0972/1113] Bump uv to 0.8.9 (#150542) --- Dockerfile | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 549837ddef0..4a004c046e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,7 @@ RUN \ && go2rtc --version # Install uv -RUN pip3 install uv==0.7.1 +RUN pip3 install uv==0.8.9 WORKDIR /usr/src diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7b853e57565..21f481bcb51 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -68,7 +68,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.14.0,<5.0 ulid-transform==1.4.0 urllib3>=2.0 -uv==0.7.1 +uv==0.8.9 voluptuous-openapi==0.1.0 voluptuous-serialize==2.6.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index f8af19868bd..c4b4bfcdd7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "typing-extensions>=4.14.0,<5.0", "ulid-transform==1.4.0", "urllib3>=2.0", - "uv==0.7.1", + "uv==0.8.9", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.1.0", diff --git a/requirements.txt b/requirements.txt index 7bd900a69ed..f0f49ac519b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -46,7 +46,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.14.0,<5.0 ulid-transform==1.4.0 urllib3>=2.0 -uv==0.7.1 +uv==0.8.9 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.1.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 5776f6dfe12..4b8aafce70f 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,7 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.7.1,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.8.9,source=/uv,target=/bin/uv \ # Uv creates a lock file in /tmp --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG From 8a52e9ca01b5f1ef47165b1d7e75596ade96c4d0 Mon Sep 17 00:00:00 2001 From: Yevhenii Vaskivskyi Date: Wed, 13 Aug 2025 09:46:08 +0200 Subject: [PATCH 0973/1113] Bump asusrouter to 1.18.2 (#150541) --- homeassistant/components/asuswrt/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json index 5064642619c..3f29e20f8da 100644 --- a/homeassistant/components/asuswrt/manifest.json +++ b/homeassistant/components/asuswrt/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioasuswrt", "asyncssh"], - "requirements": ["aioasuswrt==1.4.0", "asusrouter==1.18.1"] + "requirements": ["aioasuswrt==1.4.0", "asusrouter==1.18.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 85648dce34a..c9828b53e70 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -528,7 +528,7 @@ arris-tg2492lg==2.2.0 asmog==0.0.6 # homeassistant.components.asuswrt -asusrouter==1.18.1 +asusrouter==1.18.2 # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f1cee9ad504..b0f3938f5cd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -492,7 +492,7 @@ aranet4==2.5.1 arcam-fmj==1.8.2 # homeassistant.components.asuswrt -asusrouter==1.18.1 +asusrouter==1.18.2 # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms From 8a54a1d95cc67a313c5eda0c93e63fa693de4c7e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Aug 2025 03:17:20 -0500 Subject: [PATCH 0974/1113] Bump aioesphomeapi to 39.0.0 (#150523) --- homeassistant/components/esphome/manager.py | 12 +- .../components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/conftest.py | 3 + tests/components/esphome/test_config_flow.py | 80 ++++++++---- tests/components/esphome/test_entity.py | 31 +++++ tests/components/esphome/test_manager.py | 116 +++++++++++++----- tests/components/esphome/test_repairs.py | 27 ++-- 9 files changed, 195 insertions(+), 80 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 742ed266bf3..74b429cdfa1 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import base64 from functools import partial import logging @@ -15,7 +14,6 @@ from aioesphomeapi import ( APIVersion, DeviceInfo as EsphomeDeviceInfo, EncryptionPlaintextAPIError, - EntityInfo, HomeassistantServiceCall, InvalidAuthAPIError, InvalidEncryptionKeyAPIError, @@ -63,7 +61,6 @@ from homeassistant.helpers.issue_registry import ( ) from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.template import Template -from homeassistant.util.async_ import create_eager_task from .bluetooth import async_connect_scanner from .const import ( @@ -425,14 +422,7 @@ class ESPHomeManager: unique_id_is_mac_address = unique_id and ":" in unique_id if entry.options.get(CONF_SUBSCRIBE_LOGS): self._async_subscribe_logs(self._async_get_equivalent_log_level()) - results = await asyncio.gather( - create_eager_task(cli.device_info()), - create_eager_task(cli.list_entities_services()), - ) - - device_info: EsphomeDeviceInfo = results[0] - entity_infos_services: tuple[list[EntityInfo], list[UserService]] = results[1] - entity_infos, services = entity_infos_services + device_info, entity_infos, services = await cli.device_info_and_list_entities() device_mac = format_mac(device_info.mac_address) mac_address_matches = unique_id == device_mac diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index fafeecc1304..ffb02571742 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==38.2.1", + "aioesphomeapi==39.0.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.1.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index c9828b53e70..e9a3aab5087 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==38.2.1 +aioesphomeapi==39.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0f3938f5cd..7f60bb9b617 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==38.2.1 +aioesphomeapi==39.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 35885722d8a..f9383d3b4f7 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -517,6 +517,9 @@ async def _mock_generic_device_entry( mock_client.list_entities_services = AsyncMock( return_value=mock_list_entities_services ) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(mock_device.device_info, *mock_list_entities_services) + ) def _subscribe_home_assistant_states_and_services( *, diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 0fda7714dd0..1bedc6d79f8 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -1045,8 +1045,11 @@ async def test_encryption_key_valid_psk( assert result["step_id"] == "encryption_key" assert result["description_placeholders"] == {"name": "ESPHome"} - mock_client.device_info = AsyncMock( - return_value=DeviceInfo(uses_password=False, name="test") + device_info = DeviceInfo(uses_password=False, name="test") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -1363,10 +1366,13 @@ async def test_reauth_confirm_invalid( assert result["errors"] assert result["errors"]["base"] == "invalid_psk" - mock_client.device_info = AsyncMock( - return_value=DeviceInfo( - uses_password=False, name="test", mac_address="11:22:33:44:55:aa" - ) + device_info = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -1404,10 +1410,13 @@ async def test_reauth_confirm_invalid_with_unique_id( assert result["errors"] assert result["errors"]["base"] == "invalid_psk" - mock_client.device_info = AsyncMock( - return_value=DeviceInfo( - uses_password=False, name="test", mac_address="11:22:33:44:55:aa" - ) + device_info = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -1460,8 +1469,11 @@ async def test_discovery_dhcp_updates_host( unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) - mock_client.device_info = AsyncMock( - return_value=DeviceInfo(name="test8266", mac_address="1122334455aa") + device_info = DeviceInfo(name="test8266", mac_address="1122334455aa") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) service_info = DhcpServiceInfo( @@ -1496,8 +1508,11 @@ async def test_discovery_dhcp_does_not_update_host_wrong_mac( unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) - mock_client.device_info = AsyncMock( - return_value=DeviceInfo(name="test8266", mac_address="1122334455ff") + device_info = DeviceInfo(name="test8266", mac_address="1122334455ff") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) service_info = DhcpServiceInfo( @@ -1602,7 +1617,12 @@ async def test_discovery_dhcp_no_changes( ) entry.add_to_hass(hass) - mock_client.device_info = AsyncMock(return_value=DeviceInfo(name="test8266")) + device_info = DeviceInfo(name="test8266") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) + ) service_info = DhcpServiceInfo( ip="192.168.43.183", @@ -2034,12 +2054,15 @@ async def test_user_flow_name_conflict_migrate( unique_id="11:22:33:44:55:cc", ) existing_entry.add_to_hass(hass) - mock_client.device_info = AsyncMock( - return_value=DeviceInfo( - uses_password=False, - name="test", - mac_address="11:22:33:44:55:AA", - ) + device_info = DeviceInfo( + uses_password=False, + name="test", + mac_address="11:22:33:44:55:AA", + ) + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) result = await hass.config_entries.flow.async_init( @@ -2084,12 +2107,15 @@ async def test_user_flow_name_conflict_overwrite( unique_id="11:22:33:44:55:cc", ) existing_entry.add_to_hass(hass) - mock_client.device_info = AsyncMock( - return_value=DeviceInfo( - uses_password=False, - name="test", - mac_address="11:22:33:44:55:AA", - ) + device_info = DeviceInfo( + uses_password=False, + name="test", + mac_address="11:22:33:44:55:AA", + ) + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) result = await hass.config_entries.flow.async_init( diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index 9b3c08bb77d..8f2d7c33575 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -214,6 +214,9 @@ async def test_entities_removed_after_reload( mock_device.client.list_entities_services = AsyncMock( return_value=(entity_info, []) ) + mock_device.client.device_info_and_list_entities = AsyncMock( + return_value=(mock_device.device_info, entity_info, []) + ) assert await hass.config_entries.async_setup(entry.entry_id) on_future = hass.loop.create_future() @@ -677,6 +680,13 @@ async def test_deep_sleep_added_after_setup( **{**asdict(mock_device.device_info), "has_deep_sleep": True} ) mock_device.client.device_info = AsyncMock(return_value=new_device_info) + mock_device.client.device_info_and_list_entities = AsyncMock( + return_value=( + new_device_info, + mock_device.client.list_entities_services.return_value[0], + mock_device.client.list_entities_services.return_value[1], + ) + ) mock_device.device_info = new_device_info await mock_device.mock_connect() @@ -952,6 +962,9 @@ async def test_entity_switches_between_devices( mock_client.list_entities_services = AsyncMock( return_value=(updated_entity_info, []) ) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device.device_info, updated_entity_info, []) + ) # Trigger a reconnect to simulate the entity info update await device.mock_disconnect(expected_disconnect=False) await device.mock_connect() @@ -979,6 +992,9 @@ async def test_entity_switches_between_devices( mock_client.list_entities_services = AsyncMock( return_value=(updated_entity_info, []) ) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device.device_info, updated_entity_info, []) + ) await device.mock_disconnect(expected_disconnect=False) await device.mock_connect() @@ -1005,6 +1021,9 @@ async def test_entity_switches_between_devices( mock_client.list_entities_services = AsyncMock( return_value=(updated_entity_info, []) ) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device.device_info, updated_entity_info, []) + ) await device.mock_disconnect(expected_disconnect=False) await device.mock_connect() @@ -1228,6 +1247,9 @@ async def test_unique_id_migration_when_entity_moves_between_devices( # Update the entity info by changing what the mock returns mock_client.list_entities_services = AsyncMock(return_value=(new_entity_info, [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device.device_info, new_entity_info, []) + ) # Trigger a reconnect to simulate the entity info update await device.mock_disconnect(expected_disconnect=False) @@ -1322,6 +1344,9 @@ async def test_unique_id_migration_sub_device_to_main_device( # Update the entity info mock_client.list_entities_services = AsyncMock(return_value=(new_entity_info, [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device.device_info, new_entity_info, []) + ) # Trigger a reconnect await device.mock_disconnect(expected_disconnect=False) @@ -1415,6 +1440,9 @@ async def test_unique_id_migration_between_sub_devices( # Update the entity info mock_client.list_entities_services = AsyncMock(return_value=(new_entity_info, [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device.device_info, new_entity_info, []) + ) # Trigger a reconnect await device.mock_disconnect(expected_disconnect=False) @@ -1534,6 +1562,9 @@ async def test_entity_device_id_rename_in_yaml( # Update the entity info mock_client.list_entities_services = AsyncMock(return_value=(new_entity_info, [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(new_device_info, new_entity_info, []) + ) # Trigger a reconnect to simulate the YAML config change await device.mock_disconnect(expected_disconnect=False) diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index c29dbad1d37..86dfb6e9ea3 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -422,10 +422,11 @@ async def test_unique_id_updated_to_mac( mock_client.subscribe_home_assistant_states_and_services = ( async_subscribe_home_assistant_states_and_services ) - mock_client.device_info = AsyncMock( - return_value=DeviceInfo( - mac_address="1122334455aa", - ) + device_info = DeviceInfo(mac_address="1122334455aa") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) @@ -455,11 +456,14 @@ async def test_add_missing_bluetooth_mac_address( mock_client.subscribe_home_assistant_states_and_services = ( async_subscribe_home_assistant_states_and_services ) - mock_client.device_info = AsyncMock( - return_value=DeviceInfo( - mac_address="1122334455aa", - bluetooth_mac_address="AA:BB:CC:DD:EE:FF", - ) + device_info = DeviceInfo( + mac_address="1122334455aa", + bluetooth_mac_address="AA:BB:CC:DD:EE:FF", + ) + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) @@ -493,8 +497,11 @@ async def test_unique_id_not_updated_if_name_same_and_already_mac( disconnect_done.set_result(None) mock_client.disconnect = async_disconnect - mock_client.device_info = AsyncMock( - return_value=DeviceInfo(mac_address="1122334455ab", name="test") + device_info = DeviceInfo(mac_address="1122334455ab", name="test") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) @@ -523,8 +530,11 @@ async def test_unique_id_updated_if_name_unset_and_already_mac( disconnect_done.set_result(None) mock_client.disconnect = async_disconnect - mock_client.device_info = AsyncMock( - return_value=DeviceInfo(mac_address="1122334455ab", name="test") + device_info = DeviceInfo(mac_address="1122334455ab", name="test") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) @@ -558,8 +568,11 @@ async def test_unique_id_not_updated_if_name_different_and_already_mac( disconnect_done.set_result(None) mock_client.disconnect = async_disconnect - mock_client.device_info = AsyncMock( - return_value=DeviceInfo(mac_address="1122334455ab", name="different") + device_info = DeviceInfo(mac_address="1122334455ab", name="different") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) @@ -597,8 +610,11 @@ async def test_name_updated_only_if_mac_matches( mock_client.subscribe_home_assistant_states_and_services = ( async_subscribe_home_assistant_states_and_services ) - mock_client.device_info = AsyncMock( - return_value=DeviceInfo(mac_address="1122334455aa", name="new") + device_info = DeviceInfo(mac_address="1122334455aa", name="new") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) @@ -634,8 +650,11 @@ async def test_name_updated_only_if_mac_was_unset( mock_client.subscribe_home_assistant_states_and_services = ( async_subscribe_home_assistant_states_and_services ) - mock_client.device_info = AsyncMock( - return_value=DeviceInfo(mac_address="1122334455aa", name="new") + device_info = DeviceInfo(mac_address="1122334455aa", name="new") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) @@ -672,8 +691,11 @@ async def test_connection_aborted_wrong_device( disconnect_done.set_result(None) mock_client.disconnect = async_disconnect - mock_client.device_info = AsyncMock( - return_value=DeviceInfo(mac_address="1122334455ab", name="different") + device_info = DeviceInfo(mac_address="1122334455ab", name="different") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) @@ -703,10 +725,12 @@ async def test_connection_aborted_wrong_device( hostname="test", macaddress="1122334455aa", ) - new_info = AsyncMock( - return_value=DeviceInfo(mac_address="1122334455aa", name="test") - ) + device_info = DeviceInfo(mac_address="1122334455aa", name="test") + new_info = AsyncMock(return_value=device_info) mock_client.device_info = new_info + # Also need to update device_info_and_list_entities + new_combined_info = AsyncMock(return_value=(device_info, [], [])) + mock_client.device_info_and_list_entities = new_combined_info result = await hass.config_entries.flow.async_init( "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info ) @@ -720,7 +744,8 @@ async def test_connection_aborted_wrong_device( } assert entry.data[CONF_HOST] == "192.168.43.184" await hass.async_block_till_done() - assert len(new_info.mock_calls) == 2 + # Check that either device_info or device_info_and_list_entities was called + assert len(new_info.mock_calls) + len(new_combined_info.mock_calls) == 2 assert "Unexpected device found at" not in caplog.text @@ -749,8 +774,11 @@ async def test_connection_aborted_wrong_device_same_name( disconnect_done.set_result(None) mock_client.disconnect = async_disconnect - mock_client.device_info = AsyncMock( - return_value=DeviceInfo(mac_address="1122334455ab", name="test") + device_info = DeviceInfo(mac_address="1122334455ab", name="test") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) @@ -777,10 +805,12 @@ async def test_connection_aborted_wrong_device_same_name( hostname="test", macaddress="1122334455aa", ) - new_info = AsyncMock( - return_value=DeviceInfo(mac_address="1122334455aa", name="test") - ) + device_info = DeviceInfo(mac_address="1122334455aa", name="test") + new_info = AsyncMock(return_value=device_info) mock_client.device_info = new_info + # Also need to update device_info_and_list_entities + new_combined_info = AsyncMock(return_value=(device_info, [], [])) + mock_client.device_info_and_list_entities = new_combined_info result = await hass.config_entries.flow.async_init( "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info ) @@ -794,7 +824,8 @@ async def test_connection_aborted_wrong_device_same_name( } assert entry.data[CONF_HOST] == "192.168.43.184" await hass.async_block_till_done() - assert len(new_info.mock_calls) == 2 + # Check that either device_info or device_info_and_list_entities was called + assert len(new_info.mock_calls) + len(new_combined_info.mock_calls) == 2 assert "Unexpected device found at" not in caplog.text @@ -823,6 +854,12 @@ async def test_failure_during_connect( mock_client.disconnect = async_disconnect mock_client.device_info = AsyncMock(side_effect=APIConnectionError("fail")) + mock_client.list_entities_services = AsyncMock( + side_effect=APIConnectionError("fail") + ) + mock_client.device_info_and_list_entities = AsyncMock( + side_effect=APIConnectionError("fail") + ) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -989,6 +1026,9 @@ async def test_esphome_device_with_dash_in_name_user_services( # Verify the service can be removed mock_client.list_entities_services = AsyncMock(return_value=([], [service1])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device.device_info, [], [service1]) + ) await device.mock_disconnect(True) await hass.async_block_till_done() await device.mock_connect() @@ -1045,6 +1085,9 @@ async def test_esphome_user_services_ignores_invalid_arg_types( # Verify the service can be removed mock_client.list_entities_services = AsyncMock(return_value=([], [service2])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device.device_info, [], [service2]) + ) await device.mock_disconnect(True) await hass.async_block_till_done() await device.mock_connect() @@ -1153,6 +1196,9 @@ async def test_esphome_user_services_changes( # Verify the service can be updated mock_client.list_entities_services = AsyncMock(return_value=([], [new_service1])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device.device_info, [], [new_service1]) + ) await device.mock_disconnect(True) await hass.async_block_till_done() await device.mock_connect() @@ -1486,6 +1532,10 @@ async def test_device_adds_friendly_name( **{**device.device_info.to_dict(), "friendly_name": "I have a friendly name"} ) mock_client.device_info = AsyncMock(return_value=device.device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device.device_info, [], []) + ) await device.mock_connect() await hass.async_block_till_done() dev = dev_reg.async_get_device( @@ -1676,6 +1726,10 @@ async def test_sub_device_cleanup( # Update the mock client to return the new device info mock_client.device_info = AsyncMock(return_value=device.device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device.device_info, [], []) + ) # Simulate reconnection which triggers device registry update await device.mock_connect() diff --git a/tests/components/esphome/test_repairs.py b/tests/components/esphome/test_repairs.py index f5142367432..f64cb806950 100644 --- a/tests/components/esphome/test_repairs.py +++ b/tests/components/esphome/test_repairs.py @@ -55,10 +55,13 @@ async def test_device_conflict_manual( disconnect_done.set_result(None) mock_client.disconnect = async_disconnect - mock_client.device_info = AsyncMock( - return_value=DeviceInfo( - mac_address="1122334455ab", name="test", model="esp32-iso-poe" - ) + device_info = DeviceInfo( + mac_address="1122334455ab", name="test", model="esp32-iso-poe" + ) + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -102,10 +105,13 @@ async def test_device_conflict_manual( assert data["type"] == FlowResultType.FORM assert data["step_id"] == "manual" - mock_client.device_info = AsyncMock( - return_value=DeviceInfo( - mac_address="11:22:33:44:55:aa", name="test", model="esp32-iso-poe" - ) + device_info = DeviceInfo( + mac_address="11:22:33:44:55:aa", name="test", model="esp32-iso-poe" + ) + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) caplog.clear() data = await process_repair_fix_flow(client, flow_id) @@ -169,6 +175,11 @@ async def test_device_conflict_migration( mac_address="11:22:33:44:55:AB", name="test", model="esp32-iso-poe" ) mock_client.device_info = AsyncMock(return_value=new_device_info) + # Keep the same entity_info when reloading + mock_client.list_entities_services = AsyncMock(return_value=(entity_info, [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(new_device_info, entity_info, []) + ) device.device_info = new_device_info await hass.config_entries.async_reload(mock_config_entry.entry_id) await hass.async_block_till_done() From 53e40a6b8cfbc20a020a893879dba0dc5aa5aef2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 13 Aug 2025 11:25:59 +0200 Subject: [PATCH 0975/1113] Ensure Tuya humidifiers have at least one valid DPCode (#150546) --- homeassistant/components/tuya/humidifier.py | 18 ++- .../tuya/snapshots/test_humidifier.ambr | 116 +----------------- tests/components/tuya/test_humidifier.py | 27 +++- 3 files changed, 41 insertions(+), 120 deletions(-) diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index 5def5c5e16c..cb08ccaf476 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -35,6 +35,20 @@ class TuyaHumidifierEntityDescription(HumidifierEntityDescription): humidity: DPCode | None = None +def _has_a_valid_dpcode( + device: CustomerDevice, description: TuyaHumidifierEntityDescription +) -> bool: + """Check if the device has at least one valid DP code.""" + properties_to_check: list[DPCode | tuple[DPCode, ...] | None] = [ + # Main control switch + description.dpcode or DPCode(description.key), + # Other humidity properties + description.current_humidity, + description.humidity, + ] + return any(get_dpcode(device, code) for code in properties_to_check) + + HUMIDIFIERS: dict[str, TuyaHumidifierEntityDescription] = { # Dehumidifier # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha @@ -71,7 +85,9 @@ async def async_setup_entry( entities: list[TuyaHumidifierEntity] = [] for device_id in device_ids: device = hass_data.manager.device_map[device_id] - if description := HUMIDIFIERS.get(device.category): + if ( + description := HUMIDIFIERS.get(device.category) + ) and _has_a_valid_dpcode(device, description): entities.append( TuyaHumidifierEntity(device, hass_data.manager, description) ) diff --git a/tests/components/tuya/snapshots/test_humidifier.ambr b/tests/components/tuya/snapshots/test_humidifier.ambr index ab172241bfa..46535810d7d 100644 --- a/tests/components/tuya/snapshots/test_humidifier.ambr +++ b/tests/components/tuya/snapshots/test_humidifier.ambr @@ -55,61 +55,6 @@ }) # --- # name: test_platform_setup_and_discovery[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.ilms5pwjzzsxuxmvscswitch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[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[humidifier.dehumidifier_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -125,7 +70,7 @@ 'disabled_by': None, 'domain': 'humidifier', 'entity_category': None, - 'entity_id': 'humidifier.dehumidifier_2', + 'entity_id': 'humidifier.dehumidifier', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -147,7 +92,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[humidifier.dehumidifier_2-state] +# name: test_platform_setup_and_discovery[humidifier.dehumidifier-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_humidity': 47, @@ -159,65 +104,10 @@ 'supported_features': , }), 'context': , - 'entity_id': 'humidifier.dehumidifier_2', + 'entity_id': 'humidifier.dehumidifier', '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/test_humidifier.py b/tests/components/tuya/test_humidifier.py index debfb765d8b..33f715176bd 100644 --- a/tests/components/tuya/test_humidifier.py +++ b/tests/components/tuya/test_humidifier.py @@ -126,7 +126,7 @@ async def test_set_humidity( @pytest.mark.parametrize( "mock_device_code", - ["cs_vmxuxszzjwp5smli"], + ["cs_zibqa9dutqyaxym2"], ) async def test_turn_on_unsupported( hass: HomeAssistant, @@ -135,6 +135,11 @@ async def test_turn_on_unsupported( mock_device: CustomerDevice, ) -> None: """Test turn on service (not supported by this device).""" + # Remove switch control - but keep other functionality + mock_device.status.pop("switch") + mock_device.function.pop("switch") + mock_device.status_range.pop("switch") + entity_id = "humidifier.dehumidifier" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) @@ -150,13 +155,13 @@ async def test_turn_on_unsupported( assert err.value.translation_key == "action_dpcode_not_found" assert err.value.translation_placeholders == { "expected": "['switch', 'switch_spray']", - "available": ("[]"), + "available": ("['child_lock', 'countdown_set', 'dehumidify_set_value']"), } @pytest.mark.parametrize( "mock_device_code", - ["cs_vmxuxszzjwp5smli"], + ["cs_zibqa9dutqyaxym2"], ) async def test_turn_off_unsupported( hass: HomeAssistant, @@ -165,6 +170,11 @@ async def test_turn_off_unsupported( mock_device: CustomerDevice, ) -> None: """Test turn off service (not supported by this device).""" + # Remove switch control - but keep other functionality + mock_device.status.pop("switch") + mock_device.function.pop("switch") + mock_device.status_range.pop("switch") + entity_id = "humidifier.dehumidifier" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) @@ -180,13 +190,13 @@ async def test_turn_off_unsupported( assert err.value.translation_key == "action_dpcode_not_found" assert err.value.translation_placeholders == { "expected": "['switch', 'switch_spray']", - "available": ("[]"), + "available": ("['child_lock', 'countdown_set', 'dehumidify_set_value']"), } @pytest.mark.parametrize( "mock_device_code", - ["cs_vmxuxszzjwp5smli"], + ["cs_zibqa9dutqyaxym2"], ) async def test_set_humidity_unsupported( hass: HomeAssistant, @@ -195,6 +205,11 @@ async def test_set_humidity_unsupported( mock_device: CustomerDevice, ) -> None: """Test set humidity service (not supported by this device).""" + # Remove set humidity control - but keep other functionality + mock_device.status.pop("dehumidify_set_value") + mock_device.function.pop("dehumidify_set_value") + mock_device.status_range.pop("dehumidify_set_value") + entity_id = "humidifier.dehumidifier" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) @@ -213,5 +228,5 @@ async def test_set_humidity_unsupported( assert err.value.translation_key == "action_dpcode_not_found" assert err.value.translation_placeholders == { "expected": "['dehumidify_set_value']", - "available": ("[]"), + "available": ("['child_lock', 'countdown_set', 'switch']"), } From 6454f40c3c614e99e6fc61e5385d5b8bfed98679 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Aug 2025 11:36:43 +0200 Subject: [PATCH 0976/1113] Bump github/codeql-action from 3.29.8 to 3.29.9 (#150539) 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 6a67a4b94de..8673c5f4b87 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v5.0.0 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.29.8 + uses: github/codeql-action/init@v3.29.9 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.29.8 + uses: github/codeql-action/analyze@v3.29.9 with: category: "/language:python" From 6d34d34ce1b321e2b492cc672f32a831dd04bccd Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 13 Aug 2025 05:38:18 -0400 Subject: [PATCH 0977/1113] Bump python-snoo to 0.8.1 (#150530) --- homeassistant/components/snoo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/snoo/const.py | 7 ++++++- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/snoo/manifest.json b/homeassistant/components/snoo/manifest.json index b47947ab0e0..0db11c5b086 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.7.0"] + "requirements": ["python-snoo==0.8.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index e9a3aab5087..7bfc45ae522 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.7.0 +python-snoo==0.8.1 # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7f60bb9b617..3cc54a40ab8 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.7.0 +python-snoo==0.8.1 # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/tests/components/snoo/const.py b/tests/components/snoo/const.py index 2657048afb8..cd52679caf9 100644 --- a/tests/components/snoo/const.py +++ b/tests/components/snoo/const.py @@ -31,7 +31,12 @@ MOCK_SNOO_DEVICES = [ "name": "Test Snoo", "presence": {}, "presenceIoT": {}, - "awsIoT": {}, + "awsIoT": { + "awsRegion": "us-east-1", + "clientEndpoint": "z00023244d7fia4appr4b-ats.iot.us-east-1.amazonaws.com", + "clientReady": True, + "thingName": "676cbbe74529f85038b2e623_5831231335004715141_prod", + }, "lastSSID": {}, "provisionedAt": "random_time", } From 5a7f7d90a0f1c80c886e6ed4fa4663c08d464658 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Wed, 13 Aug 2025 11:45:05 +0200 Subject: [PATCH 0978/1113] move Volvo car connection status sensor to diagnostic section (#150487) --- homeassistant/components/volvo/sensor.py | 1 + tests/components/volvo/snapshots/test_sensor.ambr | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/volvo/sensor.py b/homeassistant/components/volvo/sensor.py index a067549f068..7f37ac42dc8 100644 --- a/homeassistant/components/volvo/sensor.py +++ b/homeassistant/components/volvo/sensor.py @@ -107,6 +107,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( "power_saving_mode", ], value_fn=_availability_status, + entity_category=EntityCategory.DIAGNOSTIC, ), # statistics endpoint VolvoSensorDescription( diff --git a/tests/components/volvo/snapshots/test_sensor.ambr b/tests/components/volvo/snapshots/test_sensor.ambr index 29e7e1e72a5..e218986517a 100644 --- a/tests/components/volvo/snapshots/test_sensor.ambr +++ b/tests/components/volvo/snapshots/test_sensor.ambr @@ -128,7 +128,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.volvo_ex30_car_connection', 'has_entity_name': True, 'hidden_by': None, @@ -1163,7 +1163,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.volvo_s90_car_connection', 'has_entity_name': True, 'hidden_by': None, @@ -1951,7 +1951,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.volvo_xc40_car_connection', 'has_entity_name': True, 'hidden_by': None, @@ -3151,7 +3151,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.volvo_xc60_car_connection', 'has_entity_name': True, 'hidden_by': None, @@ -4012,7 +4012,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.volvo_xc90_car_connection', 'has_entity_name': True, 'hidden_by': None, From 5fc2e6ed53d857bc94ca906351bbd7c805f3cd77 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 13 Aug 2025 11:59:37 +0200 Subject: [PATCH 0979/1113] Add async_update_reload_and_abort to config entry subentries (#149768) Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --- .../components/kitchen_sink/config_flow.py | 2 +- homeassistant/config_entries.py | 71 ++++- tests/test_config_entries.py | 269 ++++++++++++++++++ 3 files changed, 336 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index 056ace7011c..27a10738f48 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -146,7 +146,7 @@ class SubentryFlowHandler(ConfigSubentryFlow): """Reconfigure a sensor.""" if user_input is not None: title = user_input.pop("name") - return self.async_update_and_abort( + return self.async_update_reload_and_abort( self._get_entry(), self._get_reconfigure_subentry(), data=user_input, diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index da8e73d9566..f5ccf9c3143 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3385,6 +3385,34 @@ class ConfigSubentryFlow( return result + @callback + def _async_update( + self, + entry: ConfigEntry, + subentry: ConfigSubentry, + *, + unique_id: str | None | UndefinedType = UNDEFINED, + title: str | UndefinedType = UNDEFINED, + data: Mapping[str, Any] | UndefinedType = UNDEFINED, + data_updates: Mapping[str, Any] | UndefinedType = UNDEFINED, + ) -> bool: + """Update config subentry and return result. + + Internal to be used by update_and_abort and update_reload_and_abort methods only. + """ + + if data_updates is not UNDEFINED: + if data is not UNDEFINED: + raise ValueError("Cannot set both data and data_updates") + data = subentry.data | data_updates + return self.hass.config_entries.async_update_subentry( + entry=entry, + subentry=subentry, + unique_id=unique_id, + title=title, + data=data, + ) + @callback def async_update_and_abort( self, @@ -3404,19 +3432,52 @@ class ConfigSubentryFlow( :param title: replace the title of the subentry :param unique_id: replace the unique_id of the subentry """ - if data_updates is not UNDEFINED: - if data is not UNDEFINED: - raise ValueError("Cannot set both data and data_updates") - data = subentry.data | data_updates - self.hass.config_entries.async_update_subentry( + self._async_update( entry=entry, subentry=subentry, unique_id=unique_id, title=title, data=data, + data_updates=data_updates, ) return self.async_abort(reason="reconfigure_successful") + @callback + def async_update_reload_and_abort( + self, + entry: ConfigEntry, + subentry: ConfigSubentry, + *, + unique_id: str | None | UndefinedType = UNDEFINED, + title: str | UndefinedType = UNDEFINED, + data: Mapping[str, Any] | UndefinedType = UNDEFINED, + data_updates: Mapping[str, Any] | UndefinedType = UNDEFINED, + reload_even_if_entry_is_unchanged: bool = True, + ) -> SubentryFlowResult: + """Update config subentry, reload config entry and finish subentry flow. + + :param data: replace the subentry data with new data + :param data_updates: add items from data_updates to subentry data - existing + keys are overridden + :param title: replace the title of the subentry + :param unique_id: replace the unique_id of the subentry + :param reload_even_if_entry_is_unchanged: set this to `False` if the entry + should not be reloaded if it is unchanged + """ + result = self._async_update( + entry=entry, + subentry=subentry, + unique_id=unique_id, + title=title, + data=data, + data_updates=data_updates, + ) + if reload_even_if_entry_is_unchanged or result: + if entry.update_listeners: + raise ValueError("Cannot update and reload entry with update listeners") + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_abort(reason="reconfigure_successful") + @property def _entry_id(self) -> str: """Return config entry id.""" diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 833d28ecdd9..9a62fd421b7 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -6519,6 +6519,275 @@ async def test_update_subentry_and_abort( assert result["reason"] == "reconfigure_successful" +@pytest.mark.parametrize( + ( + "kwargs", + "expected_title", + "expected_unique_id", + "expected_data", + "raises", + "reload", # True is default + "setup_call_count", + "expected_result", + ), + [ + ( + { + "unique_id": "5678", + "title": "Updated title", + "data": {"vendor": "data2"}, + }, + "Updated title", + "5678", + {"vendor": "data2"}, + does_not_raise(), + True, + 2, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + { + "unique_id": "1234", + "title": "Test", + "data": {"vendor": "data"}, + }, + "Test", + "1234", + {"vendor": "data"}, + does_not_raise(), + True, + 2, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + { + "unique_id": "1234", + "title": "Test", + "data": {"vendor": "data"}, + }, + "Test", + "1234", + {"vendor": "data"}, + does_not_raise(), + False, + 1, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + {}, + "Test", + "1234", + {"vendor": "data"}, + does_not_raise(), + True, + 2, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + { + "data": {"buyer": "me"}, + }, + "Test", + "1234", + {"buyer": "me"}, + does_not_raise(), + True, + 2, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + {"data_updates": {"buyer": "me"}}, + "Test", + "1234", + {"vendor": "data", "buyer": "me"}, + does_not_raise(), + True, + 2, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + { + "unique_id": "5678", + "title": "Updated title", + "data": {"vendor": "data2"}, + "data_updates": {"buyer": "me"}, + }, + "Test", + "1234", + {"vendor": "data"}, + pytest.raises(ValueError), + True, + 1, + {}, + ), + ], + ids=[ + "changed_entry_default", + "unchanged_entry_default", + "unchanged_entry_no_reload", + "no_kwargs", + "replace_data", + "update_data", + "update_and_data_raises", + ], +) +async def test_update_subentry_reload_and_abort( + hass: HomeAssistant, + expected_title: str, + expected_unique_id: str, + expected_data: dict[str, Any], + kwargs: dict[str, Any], + raises: AbstractContextManager, + reload: bool, + setup_call_count: int, + expected_result: dict[str, Any], +) -> None: + """Test updating an entry and reloading.""" + subentry_id = "blabla" + entry = MockConfigEntry( + domain="comp", + unique_id="entry_unique_id", + title="entry_title", + data={}, + subentries_data=[ + config_entries.ConfigSubentryData( + data={"vendor": "data"}, + subentry_id=subentry_id, + subentry_type="test", + unique_id="1234", + title="Test", + ) + ], + ) + entry.add_to_hass(hass) + subentry = entry.subentries[subentry_id] + + setup_entry = AsyncMock(return_value=True) + + comp = MockModule( + "comp", + async_setup_entry=setup_entry, + async_unload_entry=AsyncMock(return_value=True), + ) + mock_integration(hass, comp) + mock_platform(hass, "comp.config_flow", None) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + class TestFlow(config_entries.ConfigFlow): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + async def async_step_reconfigure(self, user_input=None): + return self.async_update_reload_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + **kwargs, + reload_even_if_entry_is_unchanged=reload, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: config_entries.ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with mock_config_flow("comp", TestFlow), raises: + result = await entry.start_subentry_reconfigure_flow(hass, subentry_id) + + await hass.async_block_till_done() + + subentry = entry.subentries[subentry_id] + assert subentry.title == expected_title + assert subentry.unique_id == expected_unique_id + assert subentry.data == expected_data + assert setup_entry.call_count == setup_call_count + for k, v in expected_result.items(): + assert result[k] == v + + +async def test_update_subentry_reload_with_listener(hass: HomeAssistant) -> None: + """Test updating an entry and reloading fails with update listener.""" + subentry_id = "blabla" + entry = MockConfigEntry( + domain="comp", + unique_id="entry_unique_id", + title="entry_title", + data={}, + subentries_data=[ + config_entries.ConfigSubentryData( + data={"vendor": "data"}, + subentry_id=subentry_id, + subentry_type="test", + unique_id="1234", + title="Test", + ) + ], + ) + entry.add_to_hass(hass) + entry.add_update_listener(AsyncMock()) + + setup_entry = AsyncMock(return_value=True) + + comp = MockModule( + "comp", + async_setup_entry=setup_entry, + async_unload_entry=AsyncMock(return_value=True), + ) + mock_integration(hass, comp) + mock_platform(hass, "comp.config_flow", None) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + class TestFlow(config_entries.ConfigFlow): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + async def async_step_reconfigure(self, user_input=None): + return self.async_update_reload_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data={}, + reload_even_if_entry_is_unchanged=True, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: config_entries.ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with ( + mock_config_flow("comp", TestFlow), + pytest.raises( + ValueError, match="Cannot update and reload entry with update listeners" + ), + ): + await entry.start_subentry_reconfigure_flow(hass, subentry_id) + + async def test_reconfigure_subentry_create_subentry(hass: HomeAssistant) -> None: """Test it's not allowed to create a subentry from a subentry reconfigure flow.""" subentry_id = "blabla" From 51fbccd12509b020a15fd61f910946bde0bfe2be Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Wed, 13 Aug 2025 06:26:24 -0400 Subject: [PATCH 0980/1113] Fix Sonos CI issue part 2 (#150529) Co-authored-by: G Johansson --- tests/components/sonos/conftest.py | 21 +++++++++++++++++---- tests/components/sonos/test_switch.py | 6 ++---- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index c6230eb9344..6831e4139c2 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -167,6 +167,17 @@ async def async_autosetup_sonos(async_setup_sonos): await async_setup_sonos() +def reset_sonos_alarms(alarm_event: SonosMockEvent) -> None: + """Reset the Sonos alarms to a known state.""" + sonos_alarms = Alarms() + sonos_alarms.alarms = {} + sonos_alarms._last_zone_used = None + sonos_alarms._last_alarm_list_version = None + sonos_alarms.last_uid = None + sonos_alarms.last_id = 0 + alarm_event.variables["alarm_list_version"] = "RINCON_test:0" + + @pytest.fixture def async_setup_sonos( hass: HomeAssistant, config_entry: MockConfigEntry, fire_zgs_event, alarm_event @@ -175,9 +186,7 @@ def async_setup_sonos( async def _wrapper(): config_entry.add_to_hass(hass) - sonos_alarms = Alarms() - sonos_alarms.last_alarm_list_version = "RINCON_test:0" - alarm_event.variables["alarm_list_version"] = "RINCON_test:0" + reset_sonos_alarms(alarm_event) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) await fire_zgs_event() @@ -851,11 +860,15 @@ def zgs_event_fixture( @pytest.fixture(name="sonos_setup_two_speakers") async def sonos_setup_two_speakers( - hass: HomeAssistant, soco_factory: SoCoMockFactory + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + alarm_event: SonosMockEvent, ) -> list[MockSoCo]: """Set up home assistant with two Sonos Speakers.""" soco_lr = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room") soco_br = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") + reset_sonos_alarms(alarm_event) + await async_setup_component( hass, DOMAIN, diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index f72abc36470..f2dd3478a90 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -271,8 +271,6 @@ async def test_alarm_create_delete( async def test_alarm_change_device( hass: HomeAssistant, - async_setup_sonos, - soco: MockSoCo, alarm_clock: SonosMockService, alarm_clock_extended: SonosMockService, alarm_event: SonosMockEvent, @@ -294,7 +292,7 @@ async def test_alarm_change_device( alarm_dict["CurrentAlarmList"] = alarm_dict["CurrentAlarmList"].replace( "RINCON_test", f"{soco_lr.uid}" ) - alarm_dict["CurrentAlarmListVersion"] = f"{soco_lr.uid}:900" + alarm_dict["CurrentAlarmListVersion"] = "RINCON_test: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( @@ -327,7 +325,7 @@ async def test_alarm_change_device( 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_event.variables["alarm_list_version"] = "RINCON_test:1000" alarm_update["CurrentAlarmListVersion"] = alarm_event.increment_variable( "alarm_list_version" ) From 7fba0ca2c09ef6697723869b496476bd66c3da91 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Wed, 13 Aug 2025 03:34:58 -0700 Subject: [PATCH 0981/1113] Add 'all' option to light/switch group config flow (#149671) --- homeassistant/components/group/config_flow.py | 18 ++++++++++++++++-- homeassistant/components/group/strings.json | 8 ++++++++ tests/components/group/test_config_flow.py | 6 ++++-- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 152e629be2e..88f7d9017ab 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -146,6 +146,20 @@ async def light_switch_options_schema( ) +LIGHT_CONFIG_SCHEMA = basic_group_config_schema("light").extend( + { + vol.Required(CONF_ALL, default=False): selector.BooleanSelector(), + } +) + + +SWITCH_CONFIG_SCHEMA = basic_group_config_schema("switch").extend( + { + vol.Required(CONF_ALL, default=False): selector.BooleanSelector(), + } +) + + GROUP_TYPES = [ "binary_sensor", "button", @@ -210,7 +224,7 @@ CONFIG_FLOW = { validate_user_input=set_group_type("fan"), ), "light": SchemaFlowFormStep( - basic_group_config_schema("light"), + LIGHT_CONFIG_SCHEMA, preview="group", validate_user_input=set_group_type("light"), ), @@ -235,7 +249,7 @@ CONFIG_FLOW = { validate_user_input=set_group_type("sensor"), ), "switch": SchemaFlowFormStep( - basic_group_config_schema("switch"), + SWITCH_CONFIG_SCHEMA, preview="group", validate_user_input=set_group_type("switch"), ), diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index bb9ab4b25d8..8a9f4377a62 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -66,9 +66,13 @@ "light": { "title": "[%key:component::group::config::step::user::title%]", "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%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "all": "[%key:component::group::config::step::binary_sensor::data_description::all%]" } }, "lock": { @@ -115,9 +119,13 @@ "switch": { "title": "[%key:component::group::config::step::user::title%]", "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%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "all": "[%key:component::group::config::step::binary_sensor::data_description::all%]" } } } diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 322e6ebdad0..b1bb6e5d7bb 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -44,7 +44,8 @@ from tests.typing import WebSocketGenerator {}, ), ("fan", "on", "on", {}, {}, {}, {}), - ("light", "on", "on", {}, {}, {}, {}), + ("light", "on", "on", {}, {}, {"all": False}, {}), + ("light", "on", "on", {}, {"all": True}, {"all": True}, {}), ("lock", "locked", "locked", {}, {}, {}, {}), ("notify", STATE_UNKNOWN, "2021-01-01T23:59:59.123+00:00", {}, {}, {}, {}), ("media_player", "on", "on", {}, {}, {}, {}), @@ -57,7 +58,8 @@ from tests.typing import WebSocketGenerator {"type": "sum"}, {}, ), - ("switch", "on", "on", {}, {}, {}, {}), + ("switch", "on", "on", {}, {}, {"all": False}, {}), + ("switch", "on", "on", {}, {"all": True}, {"all": True}, {}), ], ) async def test_config_flow( From f39305f64e9be00b5c787ae1bd03413df145f666 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 13 Aug 2025 12:42:00 +0200 Subject: [PATCH 0982/1113] Remove deprecated json helper constants and function (#150111) --- homeassistant/helpers/json.py | 34 +---------------- tests/helpers/test_discovery_flow.py | 3 +- tests/helpers/test_json.py | 57 +--------------------------- 3 files changed, 5 insertions(+), 89 deletions(-) diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index 176bcfcd7c4..8af91249200 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -12,39 +12,7 @@ from typing import TYPE_CHECKING, Any, Final import orjson from homeassistant.util.file import write_utf8_file, write_utf8_file_atomic -from homeassistant.util.json import ( - JSON_DECODE_EXCEPTIONS as _JSON_DECODE_EXCEPTIONS, - JSON_ENCODE_EXCEPTIONS as _JSON_ENCODE_EXCEPTIONS, - SerializationError, - format_unserializable_data, - json_loads as _json_loads, -) - -from .deprecation import ( - DeprecatedConstant, - all_with_deprecated_constants, - check_if_deprecated_constant, - deprecated_function, - dir_with_deprecated_constants, -) - -_DEPRECATED_JSON_DECODE_EXCEPTIONS = DeprecatedConstant( - _JSON_DECODE_EXCEPTIONS, "homeassistant.util.json.JSON_DECODE_EXCEPTIONS", "2025.8" -) -_DEPRECATED_JSON_ENCODE_EXCEPTIONS = DeprecatedConstant( - _JSON_ENCODE_EXCEPTIONS, "homeassistant.util.json.JSON_ENCODE_EXCEPTIONS", "2025.8" -) -json_loads = deprecated_function( - "homeassistant.util.json.json_loads", breaks_in_ha_version="2025.8" -)(_json_loads) - -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) - +from homeassistant.util.json import SerializationError, format_unserializable_data _LOGGER = logging.getLogger(__name__) diff --git a/tests/helpers/test_discovery_flow.py b/tests/helpers/test_discovery_flow.py index dde0f209706..2cb2bb38030 100644 --- a/tests/helpers/test_discovery_flow.py +++ b/tests/helpers/test_discovery_flow.py @@ -10,6 +10,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import discovery_flow, json as json_helper from homeassistant.helpers.discovery_flow import DiscoveryKey +from homeassistant.util import json as json_util @pytest.fixture @@ -151,6 +152,6 @@ def test_discovery_key_serialize_deserialize(key: str | tuple[str]) -> None: ) serialized = json_helper.json_dumps(discovery_key_1) assert ( - discovery_flow.DiscoveryKey.from_json_dict(json_helper.json_loads(serialized)) + discovery_flow.DiscoveryKey.from_json_dict(json_util.json_loads(serialized)) == discovery_key_1 ) diff --git a/tests/helpers/test_json.py b/tests/helpers/test_json.py index 413e7e0dc9d..26ee4c675bb 100644 --- a/tests/helpers/test_json.py +++ b/tests/helpers/test_json.py @@ -13,7 +13,6 @@ from unittest.mock import Mock, patch import pytest from homeassistant.core import Event, HomeAssistant, State -from homeassistant.helpers import json as json_helper from homeassistant.helpers.json import ( ExtendedJSONEncoder, JSONEncoder as DefaultHASSJSONEncoder, @@ -27,14 +26,9 @@ from homeassistant.helpers.json import ( ) from homeassistant.util import dt as dt_util from homeassistant.util.color import RGBColor -from homeassistant.util.json import ( - JSON_DECODE_EXCEPTIONS, - JSON_ENCODE_EXCEPTIONS, - SerializationError, - load_json, -) +from homeassistant.util.json import SerializationError, load_json -from tests.common import import_and_test_deprecated_constant, json_round_trip +from tests.common import json_round_trip # Test data that can be saved as JSON TEST_JSON_A = {"a": 1, "B": "two"} @@ -350,50 +344,3 @@ def test_find_unserializable_data() -> None: BadData(), dump=partial(json.dumps, cls=MockJSONEncoder), ) == {"$(BadData).bla": bad_data} - - -def test_deprecated_json_loads(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated json_loads function. - - It was moved from helpers to util in #88099 - """ - json_helper.json_loads("{}") - assert ( - "The deprecated function json_loads was called. It will be removed " - "in HA Core 2025.8. Use homeassistant.util.json.json_loads instead" - ) in caplog.text - - -@pytest.mark.parametrize( - ("constant_name", "replacement_name", "replacement"), - [ - ( - "JSON_DECODE_EXCEPTIONS", - "homeassistant.util.json.JSON_DECODE_EXCEPTIONS", - JSON_DECODE_EXCEPTIONS, - ), - ( - "JSON_ENCODE_EXCEPTIONS", - "homeassistant.util.json.JSON_ENCODE_EXCEPTIONS", - JSON_ENCODE_EXCEPTIONS, - ), - ], -) -def test_deprecated_aliases( - caplog: pytest.LogCaptureFixture, - constant_name: str, - replacement_name: str, - replacement: Any, -) -> None: - """Test deprecated JSON_DECODE_EXCEPTIONS and JSON_ENCODE_EXCEPTIONS constants. - - They were moved from helpers to util in #88099 - """ - import_and_test_deprecated_constant( - caplog, - json_helper, - constant_name, - replacement_name, - replacement, - "2025.8", - ) From 5ad2a279184d7b2d978b3337a120412240e9b599 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 13 Aug 2025 13:06:12 +0200 Subject: [PATCH 0983/1113] Use camera name in Reolink tests (#150555) --- tests/components/reolink/conftest.py | 3 +-- .../components/reolink/test_binary_sensor.py | 8 +++---- tests/components/reolink/test_button.py | 6 +++--- tests/components/reolink/test_camera.py | 6 +++--- tests/components/reolink/test_host.py | 4 ++-- tests/components/reolink/test_init.py | 5 +++-- tests/components/reolink/test_light.py | 10 ++++----- tests/components/reolink/test_media_source.py | 21 +++++++++---------- tests/components/reolink/test_number.py | 6 +++--- tests/components/reolink/test_select.py | 6 +++--- tests/components/reolink/test_sensor.py | 6 +++--- tests/components/reolink/test_siren.py | 8 +++---- tests/components/reolink/test_switch.py | 2 -- tests/components/reolink/test_update.py | 5 ----- tests/components/reolink/test_util.py | 4 ++-- 15 files changed, 46 insertions(+), 54 deletions(-) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 48b024e0b10..f8134a515e0 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -43,7 +43,6 @@ TEST_PORT = 1234 TEST_NVR_NAME = "test_reolink_name" TEST_CAM_NAME = "test_reolink_cam" TEST_NVR_NAME2 = "test2_reolink_name" -TEST_CAM_NAME = "test_reolink_cam" TEST_USE_HTTPS = True TEST_HOST_MODEL = "RLN8-410" TEST_ITEM_NUMBER = "P000" @@ -117,7 +116,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.supported.return_value = True host_mock.item_number.return_value = TEST_ITEM_NUMBER host_mock.camera_model.return_value = TEST_CAM_MODEL - host_mock.camera_name.return_value = TEST_NVR_NAME + host_mock.camera_name.return_value = TEST_CAM_NAME host_mock.camera_hardware_version.return_value = "IPC_00001" host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000" host_mock.camera_sw_version_update_required.return_value = False diff --git a/tests/components/reolink/test_binary_sensor.py b/tests/components/reolink/test_binary_sensor.py index e6275a2108e..4bbe222fad6 100644 --- a/tests/components/reolink/test_binary_sensor.py +++ b/tests/components/reolink/test_binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant -from .conftest import TEST_DUO_MODEL, TEST_HOST_MODEL, TEST_NVR_NAME +from .conftest import TEST_CAM_NAME, TEST_DUO_MODEL, TEST_HOST_MODEL from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator @@ -31,7 +31,7 @@ async def test_motion_sensor( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.BINARY_SENSOR}.{TEST_NVR_NAME}_motion_lens_0" + entity_id = f"{Platform.BINARY_SENSOR}.{TEST_CAM_NAME}_motion_lens_0" assert hass.states.get(entity_id).state == STATE_ON reolink_host.motion_detected.return_value = False @@ -66,7 +66,7 @@ async def test_smart_ai_sensor( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.BINARY_SENSOR}.{TEST_NVR_NAME}_crossline_zone1_person" + entity_id = f"{Platform.BINARY_SENSOR}.{TEST_CAM_NAME}_crossline_zone1_person" assert hass.states.get(entity_id).state == STATE_ON reolink_host.baichuan.smart_ai_state.return_value = False @@ -106,7 +106,7 @@ async def test_tcp_callback( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.BINARY_SENSOR}.{TEST_NVR_NAME}_motion" + entity_id = f"{Platform.BINARY_SENSOR}.{TEST_CAM_NAME}_motion" assert hass.states.get(entity_id).state == STATE_ON # simulate a TCP push callback diff --git a/tests/components/reolink/test_button.py b/tests/components/reolink/test_button.py index ee51d0f0b99..1e773491938 100644 --- a/tests/components/reolink/test_button.py +++ b/tests/components/reolink/test_button.py @@ -13,7 +13,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_NAME, TEST_NVR_NAME from tests.common import MockConfigEntry @@ -29,7 +29,7 @@ async def test_button( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.BUTTON}.{TEST_NVR_NAME}_ptz_up" + entity_id = f"{Platform.BUTTON}.{TEST_CAM_NAME}_ptz_up" await hass.services.async_call( BUTTON_DOMAIN, @@ -60,7 +60,7 @@ async def test_ptz_move_service( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.BUTTON}.{TEST_NVR_NAME}_ptz_up" + entity_id = f"{Platform.BUTTON}.{TEST_CAM_NAME}_ptz_up" await hass.services.async_call( DOMAIN, diff --git a/tests/components/reolink/test_camera.py b/tests/components/reolink/test_camera.py index 4ab43de225f..99236526070 100644 --- a/tests/components/reolink/test_camera.py +++ b/tests/components/reolink/test_camera.py @@ -15,7 +15,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import TEST_DUO_MODEL, TEST_NVR_NAME +from .conftest import TEST_CAM_NAME, TEST_DUO_MODEL from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator @@ -33,7 +33,7 @@ async def test_camera( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.CAMERA}.{TEST_NVR_NAME}_fluent" + entity_id = f"{Platform.CAMERA}.{TEST_CAM_NAME}_fluent" assert hass.states.get(entity_id).state == CameraState.IDLE # check getting a image from the camera @@ -63,5 +63,5 @@ async def test_camera_no_stream_source( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.CAMERA}.{TEST_NVR_NAME}_snapshots_fluent_lens_0" + entity_id = f"{Platform.CAMERA}.{TEST_CAM_NAME}_snapshots_fluent_lens_0" assert hass.states.get(entity_id).state == CameraState.IDLE diff --git a/tests/components/reolink/test_host.py b/tests/components/reolink/test_host.py index 6ae7c66704c..194d038a32a 100644 --- a/tests/components/reolink/test_host.py +++ b/tests/components/reolink/test_host.py @@ -28,7 +28,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.network import NoURLAvailableError from homeassistant.util.aiohttp import MockRequest -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_NAME from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -92,7 +92,7 @@ async def test_webhook_callback( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.BINARY_SENSOR}.{TEST_NVR_NAME}_motion" + entity_id = f"{Platform.BINARY_SENSOR}.{TEST_CAM_NAME}_motion" webhook_id = config_entry.runtime_data.host.webhook_id unique_id = config_entry.runtime_data.host.unique_id diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 10eefccace9..662469ebc01 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -52,6 +52,7 @@ from .conftest import ( DEFAULT_PROTOCOL, TEST_BC_PORT, TEST_CAM_MODEL, + TEST_CAM_NAME, TEST_HOST, TEST_HOST_MODEL, TEST_MAC, @@ -1034,7 +1035,7 @@ async def test_privacy_mode_change_callback( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SWITCH}.{TEST_NVR_NAME}_record_audio" + entity_id = f"{Platform.SWITCH}.{TEST_CAM_NAME}_record_audio" assert hass.states.get(entity_id).state == STATE_UNAVAILABLE # simulate a TCP push callback signaling a privacy mode change @@ -1106,7 +1107,7 @@ async def test_camera_wake_callback( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SWITCH}.{TEST_NVR_NAME}_record_audio" + entity_id = f"{Platform.SWITCH}.{TEST_CAM_NAME}_record_audio" assert hass.states.get(entity_id).state == STATE_ON reolink_host.sleeping.return_value = False diff --git a/tests/components/reolink/test_light.py b/tests/components/reolink/test_light.py index c3655ec00df..80a0a7abeab 100644 --- a/tests/components/reolink/test_light.py +++ b/tests/components/reolink/test_light.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_NAME, TEST_NVR_NAME from tests.common import MockConfigEntry @@ -45,7 +45,7 @@ async def test_light_state( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.LIGHT}.{TEST_NVR_NAME}_floodlight" + entity_id = f"{Platform.LIGHT}.{TEST_CAM_NAME}_floodlight" state = hass.states.get(entity_id) assert state.state == STATE_ON @@ -63,7 +63,7 @@ async def test_light_turn_off( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.LIGHT}.{TEST_NVR_NAME}_floodlight" + entity_id = f"{Platform.LIGHT}.{TEST_CAM_NAME}_floodlight" await hass.services.async_call( LIGHT_DOMAIN, @@ -94,7 +94,7 @@ async def test_light_turn_on( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.LIGHT}.{TEST_NVR_NAME}_floodlight" + entity_id = f"{Platform.LIGHT}.{TEST_CAM_NAME}_floodlight" await hass.services.async_call( LIGHT_DOMAIN, @@ -128,7 +128,7 @@ async def test_light_turn_on_errors( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.LIGHT}.{TEST_NVR_NAME}_floodlight" + entity_id = f"{Platform.LIGHT}.{TEST_CAM_NAME}_floodlight" reolink_host.set_whiteled.side_effect = exception with pytest.raises(HomeAssistantError): diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 0308639499c..c8bc8fd9c70 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -34,10 +34,10 @@ from homeassistant.setup import async_setup_component from .conftest import ( TEST_BC_PORT, + TEST_CAM_NAME, TEST_HOST2, TEST_HOST_MODEL, TEST_MAC2, - TEST_NVR_NAME, TEST_NVR_NAME2, TEST_PASSWORD2, TEST_PORT, @@ -61,7 +61,6 @@ TEST_FILE_NAME = f"{TEST_START}00" TEST_FILE_NAME_MP4 = f"{TEST_START}00.mp4" TEST_STREAM = "main" TEST_CHANNEL = "0" -TEST_CAM_NAME = "Cam new name" TEST_MIME_TYPE = "application/x-mpegURL" TEST_MIME_TYPE_MP4 = "video/mp4" @@ -172,7 +171,7 @@ async def test_browsing( browse_res_AT_sub_id = f"RES|{entry_id}|{TEST_CHANNEL}|autotrack_sub" browse_res_AT_main_id = f"RES|{entry_id}|{TEST_CHANNEL}|autotrack_main" assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME} lens 0" + assert browse.title == f"{TEST_CAM_NAME} lens 0" assert browse.identifier == browse_resolution_id assert browse.children[0].identifier == browse_res_sub_id assert browse.children[1].identifier == browse_res_main_id @@ -188,19 +187,19 @@ async def test_browsing( browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_sub_id}") assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME} lens 0 Low res." + assert browse.title == f"{TEST_CAM_NAME} lens 0 Low res." browse = await async_browse_media( hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_AT_sub_id}" ) assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME} lens 0 Telephoto low res." + assert browse.title == f"{TEST_CAM_NAME} lens 0 Telephoto low res." browse = await async_browse_media( hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_AT_main_id}" ) assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME} lens 0 Telephoto high res." + assert browse.title == f"{TEST_CAM_NAME} lens 0 Telephoto high res." browse = await async_browse_media( hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_main_id}" @@ -210,7 +209,7 @@ async def test_browsing( browse_day_0_id = f"DAY|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY}" browse_day_1_id = f"DAY|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY2}" assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME} lens 0 High res." + assert browse.title == f"{TEST_CAM_NAME} lens 0 High res." assert browse.identifier == browse_days_id assert browse.children[0].identifier == browse_day_0_id assert browse.children[1].identifier == browse_day_1_id @@ -232,7 +231,7 @@ async def test_browsing( assert browse.domain == DOMAIN assert ( browse.title - == f"{TEST_NVR_NAME} lens 0 High res. {TEST_YEAR}/{TEST_MONTH}/{TEST_DAY}" + == f"{TEST_CAM_NAME} lens 0 High res. {TEST_YEAR}/{TEST_MONTH}/{TEST_DAY}" ) assert browse.identifier == browse_files_id assert browse.children[0].identifier == browse_file_id @@ -261,7 +260,7 @@ async def test_browsing( assert browse.domain == DOMAIN assert ( browse.title - == f"{TEST_NVR_NAME} High res. {TEST_YEAR}/{TEST_MONTH}/{TEST_DAY} Person" + == f"{TEST_CAM_NAME} High res. {TEST_YEAR}/{TEST_MONTH}/{TEST_DAY} Person" ) assert browse.identifier == browse_files_id assert browse.children[0].identifier == browse_file_id @@ -306,7 +305,7 @@ async def test_browsing_h265_encoding( browse_res_main_id = f"RES|{entry_id}|{TEST_CHANNEL}|main" assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME}" + assert browse.title == f"{TEST_CAM_NAME}" assert browse.identifier == browse_resolution_id assert browse.children[0].identifier == browse_res_sub_id assert browse.children[1].identifier == browse_res_main_id @@ -321,7 +320,7 @@ async def test_browsing_h265_encoding( f"DAY|{entry_id}|{TEST_CHANNEL}|sub|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY2}" ) assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME} Low res." + assert browse.title == f"{TEST_CAM_NAME} Low res." assert browse.identifier == browse_days_id assert browse.children[0].identifier == browse_day_0_id assert browse.children[1].identifier == browse_day_1_id diff --git a/tests/components/reolink/test_number.py b/tests/components/reolink/test_number.py index 17fc2797479..853edeefa5a 100644 --- a/tests/components/reolink/test_number.py +++ b/tests/components/reolink/test_number.py @@ -16,7 +16,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_NAME, TEST_NVR_NAME from tests.common import MockConfigEntry @@ -34,7 +34,7 @@ async def test_number( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.NUMBER}.{TEST_NVR_NAME}_volume" + entity_id = f"{Platform.NUMBER}.{TEST_CAM_NAME}_volume" assert hass.states.get(entity_id).state == "80" @@ -79,7 +79,7 @@ async def test_smart_ai_number( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.NUMBER}.{TEST_NVR_NAME}_AI_crossline_zone1_sensitivity" + entity_id = f"{Platform.NUMBER}.{TEST_CAM_NAME}_AI_crossline_zone1_sensitivity" assert hass.states.get(entity_id).state == "80" diff --git a/tests/components/reolink/test_select.py b/tests/components/reolink/test_select.py index fb0f98a6e31..5dcce747518 100644 --- a/tests/components/reolink/test_select.py +++ b/tests/components/reolink/test_select.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_NAME, TEST_NVR_NAME from tests.common import MockConfigEntry, async_fire_time_changed @@ -38,7 +38,7 @@ async def test_floodlight_mode_select( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SELECT}.{TEST_NVR_NAME}_floodlight_mode" + entity_id = f"{Platform.SELECT}.{TEST_CAM_NAME}_floodlight_mode" assert hass.states.get(entity_id).state == "auto" await hass.services.async_call( @@ -88,7 +88,7 @@ async def test_play_quick_reply_message( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SELECT}.{TEST_NVR_NAME}_play_quick_reply_message" + entity_id = f"{Platform.SELECT}.{TEST_CAM_NAME}_play_quick_reply_message" assert hass.states.get(entity_id).state == STATE_UNKNOWN await hass.services.async_call( diff --git a/tests/components/reolink/test_sensor.py b/tests/components/reolink/test_sensor.py index 9b32f70a9bd..9049d5906fc 100644 --- a/tests/components/reolink/test_sensor.py +++ b/tests/components/reolink/test_sensor.py @@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_NAME, TEST_NVR_NAME from tests.common import MockConfigEntry @@ -31,10 +31,10 @@ async def test_sensors( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SENSOR}.{TEST_NVR_NAME}_ptz_pan_position" + entity_id = f"{Platform.SENSOR}.{TEST_CAM_NAME}_ptz_pan_position" assert hass.states.get(entity_id).state == "1200" - entity_id = f"{Platform.SENSOR}.{TEST_NVR_NAME}_wi_fi_signal" + entity_id = f"{Platform.SENSOR}.{TEST_CAM_NAME}_wi_fi_signal" assert hass.states.get(entity_id).state == "-55" entity_id = f"{Platform.SENSOR}.{TEST_NVR_NAME}_sd_0_storage" diff --git a/tests/components/reolink/test_siren.py b/tests/components/reolink/test_siren.py index 43156626b12..47e0e47e57f 100644 --- a/tests/components/reolink/test_siren.py +++ b/tests/components/reolink/test_siren.py @@ -22,7 +22,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_NAME from tests.common import MockConfigEntry @@ -38,7 +38,7 @@ async def test_siren( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SIREN}.{TEST_NVR_NAME}_siren" + entity_id = f"{Platform.SIREN}.{TEST_CAM_NAME}_siren" assert hass.states.get(entity_id).state == STATE_UNKNOWN # test siren turn on @@ -98,7 +98,7 @@ async def test_siren_turn_on_errors( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SIREN}.{TEST_NVR_NAME}_siren" + entity_id = f"{Platform.SIREN}.{TEST_CAM_NAME}_siren" original = getattr(reolink_host, attr) setattr(reolink_host, attr, value) @@ -124,7 +124,7 @@ async def test_siren_turn_off_errors( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SIREN}.{TEST_NVR_NAME}_siren" + entity_id = f"{Platform.SIREN}.{TEST_CAM_NAME}_siren" reolink_host.set_siren.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): diff --git a/tests/components/reolink/test_switch.py b/tests/components/reolink/test_switch.py index 9c0f2295a20..c8a38f19d5c 100644 --- a/tests/components/reolink/test_switch.py +++ b/tests/components/reolink/test_switch.py @@ -36,7 +36,6 @@ async def test_switch( reolink_host: MagicMock, ) -> None: """Test switch entity.""" - reolink_host.camera_name.return_value = TEST_CAM_NAME reolink_host.audio_record.return_value = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): @@ -108,7 +107,6 @@ async def test_host_switch( reolink_host: MagicMock, ) -> None: """Test host switch entity.""" - reolink_host.camera_name.return_value = TEST_CAM_NAME reolink_host.email_enabled.return_value = True reolink_host.is_hub = False reolink_host.supported.return_value = True diff --git a/tests/components/reolink/test_update.py b/tests/components/reolink/test_update.py index d12b229e932..ce24734f9c1 100644 --- a/tests/components/reolink/test_update.py +++ b/tests/components/reolink/test_update.py @@ -34,8 +34,6 @@ async def test_no_update( entity_name: str, ) -> None: """Test update state when no update available.""" - 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) await hass.async_block_till_done() @@ -53,7 +51,6 @@ async def test_update_str( entity_name: str, ) -> None: """Test update state when update available with string from API.""" - 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]): @@ -75,7 +72,6 @@ async def test_update_firm( entity_name: str, ) -> None: """Test update state when update available with firmware info from reolink.com.""" - 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( @@ -174,7 +170,6 @@ async def test_update_firm_keeps_available( entity_name: str, ) -> None: """Test update entity keeps being available during update.""" - 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", diff --git a/tests/components/reolink/test_util.py b/tests/components/reolink/test_util.py index 8b730bc708b..1ebeaf902c8 100644 --- a/tests/components/reolink/test_util.py +++ b/tests/components/reolink/test_util.py @@ -31,7 +31,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr -from .conftest import TEST_NVR_NAME, TEST_UID, TEST_UID_CAM +from .conftest import TEST_CAM_NAME, TEST_UID, TEST_UID_CAM from tests.common import MockConfigEntry @@ -115,7 +115,7 @@ async def test_try_function( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.NUMBER}.{TEST_NVR_NAME}_volume" + entity_id = f"{Platform.NUMBER}.{TEST_CAM_NAME}_volume" reolink_host.set_volume.side_effect = side_effect with pytest.raises(expected.__class__) as err: From eea04558a92874cd1262a402bf431622fedb5b79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 13 Aug 2025 13:21:28 +0200 Subject: [PATCH 0984/1113] Move alexa access token updates to new handler (#150466) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Martin Hjelmare --- .../components/cloud/alexa_config.py | 53 +++++++++-------- tests/components/cloud/test_alexa_config.py | 58 +++++++++++-------- 2 files changed, 63 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 5bd40eb5b83..26fda1a405f 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -6,12 +6,16 @@ import asyncio from collections.abc import Callable from contextlib import suppress from datetime import datetime, timedelta -from http import HTTPStatus import logging from typing import TYPE_CHECKING, Any import aiohttp -from hass_nabucasa import Cloud, cloud_api +from hass_nabucasa import AlexaApiError, Cloud +from hass_nabucasa.alexa_api import ( + AlexaAccessTokenDetails, + AlexaApiNeedsRelinkError, + AlexaApiNoTokenError, +) from yarl import URL from homeassistant.components import persistent_notification @@ -146,7 +150,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._cloud_user = cloud_user self._prefs = prefs self._cloud = cloud - self._token = None + self._token: str | None = None self._token_valid: datetime | None = None self._cur_entity_prefs = async_get_assistant_settings(hass, CLOUD_ALEXA) self._alexa_sync_unsub: Callable[[], None] | None = None @@ -318,32 +322,31 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): async def async_get_access_token(self) -> str | None: """Get an access token.""" + details: AlexaAccessTokenDetails | None if self._token_valid is not None and self._token_valid > utcnow(): return self._token - resp = await cloud_api.async_alexa_access_token(self._cloud) - body = await resp.json() + try: + details = await self._cloud.alexa_api.access_token() + except AlexaApiNeedsRelinkError as exception: + if self.should_report_state: + persistent_notification.async_create( + self.hass, + ( + "There was an error reporting state to Alexa" + f" ({exception.reason}). Please re-link your Alexa skill via" + " the Alexa app to continue using it." + ), + "Alexa state reporting disabled", + "cloud_alexa_report", + ) + raise alexa_errors.RequireRelink from exception + except (AlexaApiNoTokenError, AlexaApiError) as exception: + raise alexa_errors.NoTokenAvailable from exception - if resp.status == HTTPStatus.BAD_REQUEST: - if body["reason"] in ("RefreshTokenNotFound", "UnknownRegion"): - if self.should_report_state: - persistent_notification.async_create( - self.hass, - ( - "There was an error reporting state to Alexa" - f" ({body['reason']}). Please re-link your Alexa skill via" - " the Alexa app to continue using it." - ), - "Alexa state reporting disabled", - "cloud_alexa_report", - ) - raise alexa_errors.RequireRelink - - raise alexa_errors.NoTokenAvailable - - self._token = body["access_token"] - self._endpoint = body["event_endpoint"] - self._token_valid = utcnow() + timedelta(seconds=body["expires_in"]) + self._token = details["access_token"] + self._endpoint = details["event_endpoint"] + self._token_valid = utcnow() + timedelta(seconds=details["expires_in"]) return self._token async def _async_prefs_updated(self, prefs: CloudPreferences) -> None: diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index ef7a99453f0..7fc8c73785b 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -3,6 +3,11 @@ import contextlib from unittest.mock import AsyncMock, Mock, patch +from hass_nabucasa.alexa_api import ( + AlexaApiError, + AlexaApiNeedsRelinkError, + AlexaApiNoTokenError, +) import pytest from homeassistant.components.alexa import errors @@ -195,30 +200,40 @@ async def test_alexa_config_invalidate_token( servicehandlers_server="example", auth=Mock(async_check_token=AsyncMock()), websession=async_get_clientsession(hass), + alexa_api=Mock( + access_token=AsyncMock( + return_value={ + "access_token": "mock-token", + "event_endpoint": "http://example.com/alexa_endpoint", + "expires_in": 30, + } + ) + ), ), ) token = await conf.async_get_access_token() assert token == "mock-token" - assert len(aioclient_mock.mock_calls) == 1 + assert len(conf._cloud.alexa_api.access_token.mock_calls) == 1 token = await conf.async_get_access_token() assert token == "mock-token" - assert len(aioclient_mock.mock_calls) == 1 + assert len(conf._cloud.alexa_api.access_token.mock_calls) == 1 assert conf._token_valid is not None conf.async_invalidate_access_token() assert conf._token_valid is None token = await conf.async_get_access_token() assert token == "mock-token" - assert len(aioclient_mock.mock_calls) == 2 + assert len(conf._cloud.alexa_api.access_token.mock_calls) == 2 @pytest.mark.parametrize( - ("reject_reason", "expected_exception"), + ("lib_exception", "expected_exception"), [ - ("RefreshTokenNotFound", errors.RequireRelink), - ("UnknownRegion", errors.RequireRelink), - ("OtherReason", errors.NoTokenAvailable), + (AlexaApiNeedsRelinkError("Needs relink"), errors.RequireRelink), + (AlexaApiNeedsRelinkError("UnknownRegion"), errors.RequireRelink), + (AlexaApiNoTokenError("OtherReason"), errors.NoTokenAvailable), + (AlexaApiError("OtherReason"), errors.NoTokenAvailable), ], ) async def test_alexa_config_fail_refresh_token( @@ -226,7 +241,7 @@ async def test_alexa_config_fail_refresh_token( cloud_prefs: CloudPreferences, aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, - reject_reason: str, + lib_exception: Exception, expected_exception: type[Exception], ) -> None: """Test Alexa config failing to refresh token.""" @@ -259,6 +274,15 @@ async def test_alexa_config_fail_refresh_token( servicehandlers_server="example", auth=Mock(async_check_token=AsyncMock()), websession=async_get_clientsession(hass), + alexa_api=Mock( + access_token=AsyncMock( + return_value={ + "access_token": "mock-token", + "event_endpoint": "http://example.com/alexa_endpoint", + "expires_in": 30, + } + ) + ), ), ) await conf.async_initialize() @@ -284,12 +308,7 @@ async def test_alexa_config_fail_refresh_token( # Invalidate the token and try to fetch another conf.async_invalidate_access_token() - aioclient_mock.clear_requests() - aioclient_mock.post( - "https://example/alexa/access_token", - json={"reason": reject_reason}, - status=400, - ) + conf._cloud.alexa_api.access_token.side_effect = lib_exception # Change states to trigger event listener hass.states.async_set(entity_entry.entity_id, "off") @@ -310,15 +329,8 @@ async def test_alexa_config_fail_refresh_token( # Simulate we're again authorized and token update succeeds # State reporting should now be re-enabled for Alexa - aioclient_mock.clear_requests() - aioclient_mock.post( - "https://example/alexa/access_token", - json={ - "access_token": "mock-token", - "event_endpoint": "http://example.com/alexa_endpoint", - "expires_in": 30, - }, - ) + conf._cloud.alexa_api.access_token.side_effect = None + await conf.set_authorized(True) assert cloud_prefs.alexa_report_state is True assert conf.should_report_state is True From ff694a0058e42addc63617583df6abd91381faad Mon Sep 17 00:00:00 2001 From: Foscam-wangzhengyu Date: Wed, 13 Aug 2025 19:21:39 +0800 Subject: [PATCH 0985/1113] Foscam Add prompt language and modify the default port to a more compatible (#150536) --- homeassistant/components/foscam/config_flow.py | 2 +- homeassistant/components/foscam/strings.json | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/foscam/config_flow.py b/homeassistant/components/foscam/config_flow.py index 562c3f42f8b..93ec5f909c4 100644 --- a/homeassistant/components/foscam/config_flow.py +++ b/homeassistant/components/foscam/config_flow.py @@ -26,7 +26,7 @@ from .const import CONF_RTSP_PORT, CONF_STREAM, DOMAIN, LOGGER STREAMS = ["Main", "Sub"] DEFAULT_PORT = 88 -DEFAULT_RTSP_PORT = 554 +DEFAULT_RTSP_PORT = 88 DATA_SCHEMA = vol.Schema( diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json index 29a74fdd2b4..d73833b1cae 100644 --- a/homeassistant/components/foscam/strings.json +++ b/homeassistant/components/foscam/strings.json @@ -11,7 +11,12 @@ "stream": "Stream" }, "data_description": { - "host": "The hostname or IP address of your Foscam camera." + "host": "The hostname or IP address of your Foscam camera.", + "port": "The port of your Foscam camera, default is 88.", + "username": "The username to log in to your Foscam camera.", + "password": "The password to log in to your Foscam camera.", + "rtsp_port": "The RTSP protocol port of the camera, used to pull the camera's real-time video stream. New model cameras only support RTSP ports 88 and 554, while old model cameras only support ports 88 and 65534.", + "stream": "Select the video stream type to pull. The main stream offers higher clarity but requires a better network environment." } } }, From 51413b7a8df4ff0bb270be3ff04ee89ef54b1654 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 13 Aug 2025 13:40:11 +0200 Subject: [PATCH 0986/1113] Ensure Tuya fans have at least one valid DPCode (#150550) --- homeassistant/components/tuya/fan.py | 45 +++-- tests/components/tuya/snapshots/test_fan.ambr | 154 +----------------- .../components/tuya/snapshots/test_init.ambr | 6 +- 3 files changed, 34 insertions(+), 171 deletions(-) diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index fba42ad76cf..12b6b11a297 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -26,6 +26,16 @@ from .entity import TuyaEntity from .models import EnumTypeData, IntegerTypeData from .util import get_dpcode +_DIRECTION_DPCODES = (DPCode.FAN_DIRECTION,) +_OSCILLATE_DPCODES = (DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL) +_SPEED_DPCODES = ( + DPCode.FAN_SPEED_PERCENT, + DPCode.FAN_SPEED, + DPCode.SPEED, + DPCode.FAN_SPEED_ENUM, +) +_SWITCH_DPCODES = (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH) + TUYA_SUPPORT_TYPE = { # Dehumidifier # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha @@ -47,6 +57,19 @@ TUYA_SUPPORT_TYPE = { } +def _has_a_valid_dpcode(device: CustomerDevice) -> bool: + """Check if the device has at least one valid DP code.""" + properties_to_check: list[DPCode | tuple[DPCode, ...] | None] = [ + # Main control switch + _SWITCH_DPCODES, + # Other properties + _SPEED_DPCODES, + _OSCILLATE_DPCODES, + _DIRECTION_DPCODES, + ] + return any(get_dpcode(device, code) for code in properties_to_check) + + async def async_setup_entry( hass: HomeAssistant, entry: TuyaConfigEntry, @@ -61,7 +84,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 and device.category in TUYA_SUPPORT_TYPE: + if device.category in TUYA_SUPPORT_TYPE and _has_a_valid_dpcode(device): entities.append(TuyaFanEntity(device, hass_data.manager)) async_add_entities(entities) @@ -91,9 +114,7 @@ class TuyaFanEntity(TuyaEntity, FanEntity): """Init Tuya Fan Device.""" super().__init__(device, device_manager) - self._switch = get_dpcode( - self.device, (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH) - ) + self._switch = get_dpcode(self.device, _SWITCH_DPCODES) self._attr_preset_modes = [] if enum_type := self.find_dpcode( @@ -104,31 +125,23 @@ class TuyaFanEntity(TuyaEntity, FanEntity): self._attr_preset_modes = enum_type.range # Find speed controls, can be either percentage or a set of speeds - dpcodes = ( - DPCode.FAN_SPEED_PERCENT, - DPCode.FAN_SPEED, - DPCode.SPEED, - DPCode.FAN_SPEED_ENUM, - ) if int_type := self.find_dpcode( - dpcodes, dptype=DPType.INTEGER, prefer_function=True + _SPEED_DPCODES, dptype=DPType.INTEGER, prefer_function=True ): self._attr_supported_features |= FanEntityFeature.SET_SPEED self._speed = int_type elif enum_type := self.find_dpcode( - dpcodes, dptype=DPType.ENUM, prefer_function=True + _SPEED_DPCODES, dptype=DPType.ENUM, prefer_function=True ): self._attr_supported_features |= FanEntityFeature.SET_SPEED self._speeds = enum_type - if dpcode := get_dpcode( - self.device, (DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL) - ): + if dpcode := get_dpcode(self.device, _OSCILLATE_DPCODES): self._oscillate = dpcode self._attr_supported_features |= FanEntityFeature.OSCILLATE if enum_type := self.find_dpcode( - DPCode.FAN_DIRECTION, dptype=DPType.ENUM, prefer_function=True + _DIRECTION_DPCODES, dptype=DPType.ENUM, prefer_function=True ): self._direction = enum_type self._attr_supported_features |= FanEntityFeature.DIRECTION diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index 1474aa7cccd..005420c205c 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -204,126 +204,26 @@ '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[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[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] +# name: test_platform_setup_and_discovery[fan.dehumidifier-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Dehumidifier', 'supported_features': , }), 'context': , - 'entity_id': 'fan.dehumidifier_2', + 'entity_id': 'fan.dehumidifier', '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({ @@ -556,53 +456,3 @@ '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_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index a6d5b121f49..585d1a493ed 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -1601,7 +1601,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Tower bladeless fan ', + 'model': 'Tower bladeless fan (unsupported)', 'model_id': 'ibytpo6fpnugft1c', 'name': 'Ventilador Cama', 'name_by_user': None, @@ -2593,7 +2593,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': '', + 'model': ' (unsupported)', 'model_id': 'qhxmvae667uap4zh', 'name': 'DryFix', 'name_by_user': None, @@ -2779,7 +2779,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'the Smart Dry Plus™ Connect Dehumidifier ', + 'model': 'the Smart Dry Plus™ Connect Dehumidifier (unsupported)', 'model_id': 'vmxuxszzjwp5smli', 'name': 'Dehumidifier ', 'name_by_user': None, From b40f38116433a8aea60933dfb28d8be59de1e026 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 13 Aug 2025 14:09:19 +0200 Subject: [PATCH 0987/1113] Add Tuya test fixture (#150557) --- tests/components/tuya/__init__.py | 1 + .../tuya/fixtures/cs_ipmyy4nigpqcnd8q.json | 40 +++++++++++++++ .../components/tuya/snapshots/test_init.ambr | 31 ++++++++++++ .../tuya/snapshots/test_switch.ambr | 49 +++++++++++++++++++ 4 files changed, 121 insertions(+) create mode 100644 tests/components/tuya/fixtures/cs_ipmyy4nigpqcnd8q.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index a9087b92211..c48c99da9fa 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -21,6 +21,7 @@ DEVICE_MOCKS = [ "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_ipmyy4nigpqcnd8q", # https://github.com/home-assistant/core/pull/148726 "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 diff --git a/tests/components/tuya/fixtures/cs_ipmyy4nigpqcnd8q.json b/tests/components/tuya/fixtures/cs_ipmyy4nigpqcnd8q.json new file mode 100644 index 00000000000..816c17e17c7 --- /dev/null +++ b/tests/components/tuya/fixtures/cs_ipmyy4nigpqcnd8q.json @@ -0,0 +1,40 @@ +{ + "name": "Pro Breeze 30L Compressor Dehumidifier", + "category": "cs", + "product_id": "ipmyy4nigpqcnd8q", + "product_name": "30L Dehumidifier with Max Extraction", + "online": true, + "function": { + "anion": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "anion": { + "type": "Boolean", + "value": {} + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["0", "1", "2", "3", "4", "5"] + } + }, + "countdown_left": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 1440, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "anion": false, + "fault": 0, + "countdown_left": 0 + } +} diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 585d1a493ed..52ff63dac36 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -4122,6 +4122,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[q8dncqpgin4yympisc] + 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', + 'q8dncqpgin4yympisc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '30L Dehumidifier with Max Extraction', + 'model_id': 'ipmyy4nigpqcnd8q', + 'name': 'Pro Breeze 30L Compressor Dehumidifier', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[qi94v9dmdx4fkpncqld] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index d33c91118ef..96d88a967ff 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -5658,6 +5658,55 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.pro_breeze_30l_compressor_dehumidifier_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.pro_breeze_30l_compressor_dehumidifier_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.q8dncqpgin4yympiscanion', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.pro_breeze_30l_compressor_dehumidifier_ionizer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pro Breeze 30L Compressor Dehumidifier Ionizer', + 'icon': 'mdi:atom', + }), + 'context': , + 'entity_id': 'switch.pro_breeze_30l_compressor_dehumidifier_ionizer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.qt_switch_switch_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From eb4b75a9a7b5bb7299cfabb1821896c0ad2b6e29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 13 Aug 2025 15:56:04 +0200 Subject: [PATCH 0988/1113] Extend UnitOfApparentPower with 'mVA' (#150422) Co-authored-by: Martin Hjelmare --- homeassistant/components/number/const.py | 2 +- homeassistant/components/recorder/statistics.py | 2 ++ .../components/recorder/websocket_api.py | 2 ++ homeassistant/components/sensor/const.py | 4 +++- homeassistant/const.py | 1 + homeassistant/util/unit_conversion.py | 15 +++++++++++++++ tests/components/sensor/test_init.py | 1 - tests/util/test_unit_conversion.py | 16 ++++++++++++++++ 8 files changed, 40 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index bfb74d621c3..373814cae9a 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -88,7 +88,7 @@ class NumberDeviceClass(StrEnum): APPARENT_POWER = "apparent_power" """Apparent power. - Unit of measurement: `VA` + Unit of measurement: `mVA`, `VA` """ AQI = "aqi" diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 7326519b14e..20fd1a3ce28 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -42,6 +42,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util.collection import chunked_or_all from homeassistant.util.enum import try_parse_enum from homeassistant.util.unit_conversion import ( + ApparentPowerConverter, AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, @@ -193,6 +194,7 @@ QUERY_STATISTICS_SUMMARY_SUM = ( STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { + **dict.fromkeys(ApparentPowerConverter.VALID_UNITS, ApparentPowerConverter), **dict.fromkeys(AreaConverter.VALID_UNITS, AreaConverter), **dict.fromkeys( BloodGlucoseConcentrationConverter.VALID_UNITS, diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index d052631c5f6..310e2fc85c5 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -16,6 +16,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( + ApparentPowerConverter, AreaConverter, BloodGlucoseConcentrationConverter, ConductivityConverter, @@ -59,6 +60,7 @@ UPDATE_STATISTICS_METADATA_TIME_OUT = 10 UNIT_SCHEMA = vol.Schema( { + vol.Optional("apparent_power"): vol.In(ApparentPowerConverter.VALID_UNITS), vol.Optional("area"): vol.In(AreaConverter.VALID_UNITS), vol.Optional("blood_glucose_concentration"): vol.In( BloodGlucoseConcentrationConverter.VALID_UNITS diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 5f9d5ec9ca0..251a233e1fa 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -46,6 +46,7 @@ from homeassistant.const import ( UnitOfVolumetricFlux, ) from homeassistant.util.unit_conversion import ( + ApparentPowerConverter, AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, @@ -117,7 +118,7 @@ class SensorDeviceClass(StrEnum): APPARENT_POWER = "apparent_power" """Apparent power. - Unit of measurement: `VA` + Unit of measurement: `mVA`, `VA` """ AQI = "aqi" @@ -528,6 +529,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.APPARENT_POWER: ApparentPowerConverter, SensorDeviceClass.ABSOLUTE_HUMIDITY: MassVolumeConcentrationConverter, SensorDeviceClass.AREA: AreaConverter, SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, diff --git a/homeassistant/const.py b/homeassistant/const.py index b678e02569c..8e340d8468b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -588,6 +588,7 @@ ATTR_PERSONS: Final = "persons" class UnitOfApparentPower(StrEnum): """Apparent power units.""" + MILLIVOLT_AMPERE = "mVA" VOLT_AMPERE = "VA" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 5bde108dfc1..610cf5db7a9 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, UNIT_NOT_RECOGNIZED_TEMPLATE, + UnitOfApparentPower, UnitOfArea, UnitOfBloodGlucoseConcentration, UnitOfConductivity, @@ -382,6 +383,20 @@ class MassConverter(BaseUnitConverter): } +class ApparentPowerConverter(BaseUnitConverter): + """Utility to convert apparent power values.""" + + UNIT_CLASS = "apparent_power" + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfApparentPower.MILLIVOLT_AMPERE: 1 * 1000, + UnitOfApparentPower.VOLT_AMPERE: 1, + } + VALID_UNITS = { + UnitOfApparentPower.MILLIVOLT_AMPERE, + UnitOfApparentPower.VOLT_AMPERE, + } + + class PowerConverter(BaseUnitConverter): """Utility to convert power values.""" diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 98fb9d6604a..ce21f6ea8ab 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -2958,7 +2958,6 @@ def test_device_class_units_are_complete() -> None: def test_device_class_converters_are_complete() -> None: """Test that the device class converters enum is complete.""" no_converter_device_classes = { - SensorDeviceClass.APPARENT_POWER, SensorDeviceClass.AQI, SensorDeviceClass.BATTERY, SensorDeviceClass.CO, diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 537cfb33c31..1ef66584952 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, + UnitOfApparentPower, UnitOfArea, UnitOfBloodGlucoseConcentration, UnitOfConductivity, @@ -38,6 +39,7 @@ from homeassistant.const import ( from homeassistant.exceptions import HomeAssistantError from homeassistant.util import unit_conversion from homeassistant.util.unit_conversion import ( + ApparentPowerConverter, AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, @@ -83,6 +85,7 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { EnergyConverter, InformationConverter, MassConverter, + ApparentPowerConverter, PowerConverter, PressureConverter, ReactiveEnergyConverter, @@ -138,6 +141,11 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, 1000, ), + ApparentPowerConverter: ( + UnitOfApparentPower.MILLIVOLT_AMPERE, + UnitOfApparentPower.VOLT_AMPERE, + 1000, + ), PowerConverter: (UnitOfPower.WATT, UnitOfPower.KILO_WATT, 1000), PressureConverter: (UnitOfPressure.HPA, UnitOfPressure.INHG, 33.86389), ReactiveEnergyConverter: ( @@ -615,6 +623,14 @@ _CONVERTED_VALUE: dict[ (1, UnitOfMass.STONES, 14, UnitOfMass.POUNDS), (1, UnitOfMass.STONES, 224, UnitOfMass.OUNCES), ], + ApparentPowerConverter: [ + ( + 10, + UnitOfApparentPower.MILLIVOLT_AMPERE, + 0.01, + UnitOfApparentPower.VOLT_AMPERE, + ), + ], PowerConverter: [ (10, UnitOfPower.KILO_WATT, 10000, UnitOfPower.WATT), (10, UnitOfPower.MEGA_WATT, 10e6, UnitOfPower.WATT), From 721f9a40d85b34c7f2ee8226dc805bd489fd72ab Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 13 Aug 2025 09:35:37 -0500 Subject: [PATCH 0989/1113] Add volume up/down intents for media players (#150443) --- .../components/media_player/intent.py | 132 ++++++++++++- tests/components/media_player/test_intent.py | 176 +++++++++++++++++- 2 files changed, 300 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/media_player/intent.py b/homeassistant/components/media_player/intent.py index be365694579..9b714fdf52d 100644 --- a/homeassistant/components/media_player/intent.py +++ b/homeassistant/components/media_player/intent.py @@ -1,5 +1,6 @@ """Intents for the media_player integration.""" +import asyncio from collections.abc import Iterable from dataclasses import dataclass, field import logging @@ -14,21 +15,21 @@ from homeassistant.const import ( SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_VOLUME_SET, + STATE_PLAYING, ) from homeassistant.core import Context, HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, intent +from homeassistant.helpers.entity_component import EntityComponent -from . import ( +from . import MediaPlayerDeviceClass, MediaPlayerEntity +from .browse_media import SearchMedia +from .const import ( + ATTR_MEDIA_FILTER_CLASSES, ATTR_MEDIA_VOLUME_LEVEL, DOMAIN, SERVICE_PLAY_MEDIA, SERVICE_SEARCH_MEDIA, - MediaPlayerDeviceClass, - SearchMedia, -) -from .const import ( - ATTR_MEDIA_FILTER_CLASSES, MediaClass, MediaPlayerEntityFeature, MediaPlayerState, @@ -39,6 +40,7 @@ INTENT_MEDIA_UNPAUSE = "HassMediaUnpause" INTENT_MEDIA_NEXT = "HassMediaNext" INTENT_MEDIA_PREVIOUS = "HassMediaPrevious" INTENT_SET_VOLUME = "HassSetVolume" +INTENT_SET_VOLUME_RELATIVE = "HassSetVolumeRelative" INTENT_MEDIA_SEARCH_AND_PLAY = "HassMediaSearchAndPlay" _LOGGER = logging.getLogger(__name__) @@ -127,6 +129,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None: device_classes={MediaPlayerDeviceClass}, ), ) + intent.async_register(hass, MediaSetVolumeRelativeHandler()) intent.async_register(hass, MediaSearchAndPlayHandler()) @@ -354,3 +357,120 @@ class MediaSearchAndPlayHandler(intent.IntentHandler): response.async_set_speech_slots({"media": first_result.as_dict()}) response.response_type = intent.IntentResponseType.ACTION_DONE return response + + +class MediaSetVolumeRelativeHandler(intent.IntentHandler): + """Handler for setting relative volume.""" + + description = "Increases or decreases the volume of a media player" + + intent_type = INTENT_SET_VOLUME_RELATIVE + slot_schema = { + vol.Required("volume_step"): vol.Any( + "up", + "down", + vol.All( + vol.Coerce(int), + vol.Range(min=-100, max=100), + lambda val: val / 100, + ), + ), + # Optional name/area/floor slots handled by intent matcher + vol.Optional("name"): cv.string, + vol.Optional("area"): cv.string, + vol.Optional("floor"): cv.string, + vol.Optional("preferred_area_id"): cv.string, + vol.Optional("preferred_floor_id"): cv.string, + } + platforms = {DOMAIN} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + component: EntityComponent[MediaPlayerEntity] = hass.data[DOMAIN] + + slots = self.async_validate_slots(intent_obj.slots) + volume_step = slots["volume_step"]["value"] + + # Entity name to match + name_slot = slots.get("name", {}) + entity_name: str | None = name_slot.get("value") + + # Get area/floor info + area_slot = slots.get("area", {}) + area_id = area_slot.get("value") + + floor_slot = slots.get("floor", {}) + floor_id = floor_slot.get("value") + + # Find matching entities + match_constraints = intent.MatchTargetsConstraints( + name=entity_name, + area_name=area_id, + floor_name=floor_id, + domains={DOMAIN}, + assistant=intent_obj.assistant, + features=MediaPlayerEntityFeature.VOLUME_SET, + ) + match_preferences = intent.MatchTargetsPreferences( + area_id=slots.get("preferred_area_id", {}).get("value"), + floor_id=slots.get("preferred_floor_id", {}).get("value"), + ) + match_result = intent.async_match_targets( + hass, match_constraints, match_preferences + ) + + if not match_result.is_match: + # No targets + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) + + if ( + match_result.is_match + and (len(match_result.states) > 1) + and ("name" not in intent_obj.slots) + ): + # Multiple targets not by name, so we need to check state + match_result.states = [ + s for s in match_result.states if s.state == STATE_PLAYING + ] + if not match_result.states: + # No media players are playing + raise intent.MatchFailedError( + result=intent.MatchTargetsResult( + is_match=False, no_match_reason=intent.MatchFailedReason.STATE + ), + constraints=match_constraints, + preferences=match_preferences, + ) + + target_entity_ids = {s.entity_id for s in match_result.states} + target_entities = [ + e for e in component.entities if e.entity_id in target_entity_ids + ] + + if volume_step == "up": + coros = [e.async_volume_up() for e in target_entities] + elif volume_step == "down": + coros = [e.async_volume_down() for e in target_entities] + else: + coros = [ + e.async_set_volume_level( + max(0.0, min(1.0, e.volume_level + volume_step)) + ) + for e in target_entities + ] + + try: + await asyncio.gather(*coros) + except HomeAssistantError as err: + _LOGGER.error("Error setting relative volume: %s", err) + raise intent.IntentHandleError( + f"Error setting relative volume: {err}" + ) from err + + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.ACTION_DONE + response.async_set_states(match_result.states) + return response diff --git a/tests/components/media_player/test_intent.py b/tests/components/media_player/test_intent.py index d1dc03ed12a..2b585319826 100644 --- a/tests/components/media_player/test_intent.py +++ b/tests/components/media_player/test_intent.py @@ -1,5 +1,8 @@ """The tests for the media_player platform.""" +import math +from unittest.mock import patch + import pytest from homeassistant.components.media_player import ( @@ -13,12 +16,17 @@ from homeassistant.components.media_player import ( SERVICE_VOLUME_SET, BrowseMedia, MediaClass, + MediaPlayerEntity, MediaType, SearchMedia, intent as media_player_intent, ) -from homeassistant.components.media_player.const import MediaPlayerEntityFeature +from homeassistant.components.media_player.const import ( + MediaPlayerEntityFeature, + MediaPlayerState, +) from homeassistant.const import ( + ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES, STATE_IDLE, STATE_PAUSED, @@ -32,8 +40,10 @@ from homeassistant.helpers import ( floor_registry as fr, intent, ) +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.setup import async_setup_component -from tests.common import async_mock_service +from tests.common import MockEntityPlatform, async_mock_service async def test_pause_media_player_intent(hass: HomeAssistant) -> None: @@ -873,3 +883,165 @@ async def test_search_and_play_media_player_intent_with_media_class( "media_class": {"value": "invalid_class"}, }, ) + + +@pytest.mark.parametrize( + ("direction", "volume_change", "volume_change_int"), + [("up", 0.1, 20), ("down", -0.1, -20)], +) +async def test_volume_relative_media_player_intent( + hass: HomeAssistant, direction: str, volume_change: float, volume_change_int: int +) -> None: + """Test relative volume intents for media players.""" + assert await async_setup_component(hass, DOMAIN, {}) + await media_player_intent.async_setup_intents(hass) + + component: EntityComponent[MediaPlayerEntity] = hass.data[DOMAIN] + + default_volume = 0.5 + + class VolumeTestMediaPlayer(MediaPlayerEntity): + _attr_supported_features = MediaPlayerEntityFeature.VOLUME_SET + _attr_volume_level = default_volume + _attr_volume_step = 0.1 + _attr_state = MediaPlayerState.IDLE + + async def async_set_volume_level(self, volume): + self._attr_volume_level = volume + + idle_entity = VolumeTestMediaPlayer() + idle_entity.hass = hass + idle_entity.platform = MockEntityPlatform(hass) + idle_entity.entity_id = f"{DOMAIN}.idle_media_player" + await component.async_add_entities([idle_entity]) + + hass.states.async_set( + idle_entity.entity_id, + STATE_IDLE, + attributes={ + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_SET, + ATTR_FRIENDLY_NAME: "Idle Media Player", + }, + ) + + idle_expected_volume = default_volume + + # Only 1 media player is present, so it's targeted even though its idle + assert idle_entity.volume_level is not None + assert math.isclose(idle_entity.volume_level, idle_expected_volume) + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": direction}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + idle_expected_volume += volume_change + assert math.isclose(idle_entity.volume_level, idle_expected_volume) + + # Multiple media players (playing one should be targeted) + playing_entity = VolumeTestMediaPlayer() + playing_entity.hass = hass + playing_entity.platform = MockEntityPlatform(hass) + playing_entity.entity_id = f"{DOMAIN}.playing_media_player" + await component.async_add_entities([playing_entity]) + + hass.states.async_set( + playing_entity.entity_id, + STATE_PLAYING, + attributes={ + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_SET, + ATTR_FRIENDLY_NAME: "Playing Media Player", + }, + ) + + playing_expected_volume = default_volume + assert playing_entity.volume_level is not None + assert math.isclose(playing_entity.volume_level, playing_expected_volume) + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": direction}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + playing_expected_volume += volume_change + assert math.isclose(idle_entity.volume_level, idle_expected_volume) + assert math.isclose(playing_entity.volume_level, playing_expected_volume) + + # We can still target by name even if the media player is idle + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": direction}, "name": {"value": "Idle media player"}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + idle_expected_volume += volume_change + assert math.isclose(idle_entity.volume_level, idle_expected_volume) + assert math.isclose(playing_entity.volume_level, playing_expected_volume) + + # Set relative volume by percent + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": volume_change_int}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + playing_expected_volume += volume_change_int / 100 + assert math.isclose(idle_entity.volume_level, idle_expected_volume) + assert math.isclose(playing_entity.volume_level, playing_expected_volume) + + # Test error in method + with ( + patch.object( + playing_entity, "async_volume_up", side_effect=RuntimeError("boom!") + ), + pytest.raises(intent.IntentError), + ): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": "up"}}, + ) + + # Multiple idle media players should not match + hass.states.async_set( + playing_entity.entity_id, + STATE_IDLE, + attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_SET}, + ) + + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": direction}}, + ) + + # Test feature not supported + for entity_id in (idle_entity.entity_id, playing_entity.entity_id): + hass.states.async_set( + entity_id, + STATE_PLAYING, + attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature(0)}, + ) + + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": direction}}, + ) From b40aab479aa176b74c77c3b82e3bc975e72b0fbf Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Wed, 13 Aug 2025 08:21:36 -0700 Subject: [PATCH 0990/1113] Change monetary translation to 'Monetary balance' (#150054) --- homeassistant/components/sensor/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index c69bf99eff0..a8d06f8c0e9 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -25,7 +25,7 @@ "is_illuminance": "Current {entity_name} illuminance", "is_irradiance": "Current {entity_name} irradiance", "is_moisture": "Current {entity_name} moisture", - "is_monetary": "Current {entity_name} balance", + "is_monetary": "Current {entity_name} monetary balance", "is_nitrogen_dioxide": "Current {entity_name} nitrogen dioxide concentration level", "is_nitrogen_monoxide": "Current {entity_name} nitrogen monoxide concentration level", "is_nitrous_oxide": "Current {entity_name} nitrous oxide concentration level", @@ -81,7 +81,7 @@ "illuminance": "{entity_name} illuminance changes", "irradiance": "{entity_name} irradiance changes", "moisture": "{entity_name} moisture changes", - "monetary": "{entity_name} balance changes", + "monetary": "{entity_name} monetary balance changes", "nitrogen_dioxide": "{entity_name} nitrogen dioxide concentration changes", "nitrogen_monoxide": "{entity_name} nitrogen monoxide concentration changes", "nitrous_oxide": "{entity_name} nitrous oxide concentration changes", @@ -223,7 +223,7 @@ "name": "Moisture" }, "monetary": { - "name": "Balance" + "name": "Monetary balance" }, "nitrogen_dioxide": { "name": "Nitrogen dioxide" From d18cc3d6c396955225f81b31c0aa90b5474ec3b6 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 13 Aug 2025 18:32:50 +0200 Subject: [PATCH 0991/1113] Fix RuntimeWarning in squeezebox tests (#150582) --- tests/components/squeezebox/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index 97aca31fa05..2dd9403d53f 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -271,6 +271,7 @@ def mock_pysqueezebox_player(uuid: str) -> MagicMock: "homeassistant.components.squeezebox.Player", autospec=True ) as mock_player: mock_player.async_browse = AsyncMock(side_effect=mock_async_browse) + mock_player.async_query = AsyncMock(return_value=MagicMock()) mock_player.generate_image_url_from_track_id = MagicMock( return_value="http://lms.internal:9000/html/images/favorites.png" ) From 13376ef8963e2f98cdc2ca5936e17224965eb953 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 13 Aug 2025 18:33:02 +0200 Subject: [PATCH 0992/1113] Fix RuntimeWarning in asuswrt tests (#150580) --- tests/components/asuswrt/conftest.py | 2 +- tests/components/asuswrt/test_sensor.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/components/asuswrt/conftest.py b/tests/components/asuswrt/conftest.py index e6dd42a23fd..95c8f3dbf74 100644 --- a/tests/components/asuswrt/conftest.py +++ b/tests/components/asuswrt/conftest.py @@ -222,7 +222,7 @@ def mock_controller_connect_http_sens_fail(connect_http): def mock_controller_connect_http_sens_detect(): """Mock a successful sensor detection using http library.""" - def _get_sensors_side_effect(datatype): + async def _get_sensors_side_effect(datatype): if datatype == AsusData.TEMPERATURE: return list(MOCK_TEMPERATURES_HTTP) if datatype == AsusData.CPU: diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index 3ce3246c1d6..c782605aab3 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -377,7 +377,6 @@ async def test_cpu_sensors_http( connect_http_sens_detect, ) -> None: """Test creating AsusWRT cpu sensors.""" - connect_http_sens_detect(AsusData.CPU) config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA_HTTP, SENSORS_CPU) config_entry.add_to_hass(hass) From 2c0ed2cbfe2075b5de3290647eb63bff20593f35 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 13 Aug 2025 11:57:25 -0500 Subject: [PATCH 0993/1113] Add intent for setting fan speed (#150576) --- homeassistant/components/fan/intent.py | 31 +++++++++++++++++++++ tests/components/fan/test_intent.py | 37 ++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 homeassistant/components/fan/intent.py create mode 100644 tests/components/fan/test_intent.py diff --git a/homeassistant/components/fan/intent.py b/homeassistant/components/fan/intent.py new file mode 100644 index 00000000000..ef088a4bba9 --- /dev/null +++ b/homeassistant/components/fan/intent.py @@ -0,0 +1,31 @@ +"""Intents for the fan integration.""" + +import voluptuous as vol + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent + +from . import ATTR_PERCENTAGE, DOMAIN, SERVICE_TURN_ON + +INTENT_FAN_SET_SPEED = "HassFanSetSpeed" + + +async def async_setup_intents(hass: HomeAssistant) -> None: + """Set up the fan intents.""" + intent.async_register( + hass, + intent.ServiceIntentHandler( + INTENT_FAN_SET_SPEED, + DOMAIN, + SERVICE_TURN_ON, + description="Sets a fan's speed by percentage", + required_domains={DOMAIN}, + platforms={DOMAIN}, + required_slots={ + ATTR_PERCENTAGE: intent.IntentSlotInfo( + description="The speed percentage of the fan", + value_schema=vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), + ) + }, + ), + ) diff --git a/tests/components/fan/test_intent.py b/tests/components/fan/test_intent.py new file mode 100644 index 00000000000..450d81e9dff --- /dev/null +++ b/tests/components/fan/test_intent.py @@ -0,0 +1,37 @@ +"""Intent tests for the fan platform.""" + +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + DOMAIN, + SERVICE_TURN_ON, + intent as fan_intent, +) +from homeassistant.const import STATE_OFF +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent + +from tests.common import async_mock_service + + +async def test_set_speed_intent(hass: HomeAssistant) -> None: + """Test set speed intent for fans.""" + await fan_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_fan" + hass.states.async_set(entity_id, STATE_OFF) + calls = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) + + response = await intent.async_handle( + hass, + "test", + fan_intent.INTENT_FAN_SET_SPEED, + {"name": {"value": "test fan"}, ATTR_PERCENTAGE: {"value": 50}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + call = calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data == {"entity_id": entity_id, "percentage": 50} From f7726a7563defa2cb34db2223f343f80b57e5800 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 13 Aug 2025 19:23:26 +0200 Subject: [PATCH 0994/1113] Update pre-commit-hooks to 6.0.0 (#150583) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 610fed902ad..d87187b55be 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: exclude_types: [csv, json, html] exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/ - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-executables-have-shebangs stages: [manual] From bda82e19a548107d04b0dde8b6be7784fb74af53 Mon Sep 17 00:00:00 2001 From: HarvsG <11440490+HarvsG@users.noreply.github.com> Date: Wed, 13 Aug 2025 19:53:21 +0100 Subject: [PATCH 0995/1113] Pi_hole - Account for auth succeeding when it shouldn't (#150413) --- homeassistant/components/pi_hole/__init__.py | 7 +++++++ tests/components/pi_hole/__init__.py | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index f73b7156d3e..ae51fe166c4 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -217,6 +217,13 @@ async def determine_api_version( _LOGGER.debug( "Connection to %s failed: %s, trying API version 5", holeV6.base_url, ex_v6 ) + else: + # It seems that occasionally the auth can succeed unexpectedly when there is a valid session + _LOGGER.warning( + "Authenticated with %s through v6 API, but succeeded with an incorrect password. This is a known bug", + holeV6.base_url, + ) + return 6 holeV5 = api_by_version(hass, entry, 5, password="wrong_token") try: await holeV5.get_data() diff --git a/tests/components/pi_hole/__init__.py b/tests/components/pi_hole/__init__.py index c20f22ac58d..c2edb51e066 100644 --- a/tests/components/pi_hole/__init__.py +++ b/tests/components/pi_hole/__init__.py @@ -221,12 +221,16 @@ def _create_mocked_hole( if wrong_host: raise HoleConnectionError("Cannot authenticate with Pi-hole: err") password = getattr(mocked_hole, "password", None) + if ( raise_exception or incorrect_app_password + or api_version == 5 or (api_version == 6 and password not in ["newkey", "apikey"]) ): - if api_version == 6: + if api_version == 6 and ( + incorrect_app_password or password not in ["newkey", "apikey"] + ): raise HoleError("Authentication failed: Invalid password") raise HoleConnectionError From 12c346f55090c2a1dd1c3e765153528aad49dac7 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 13 Aug 2025 20:53:55 +0200 Subject: [PATCH 0996/1113] Update orjson to 3.11.2 (#150588) --- 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 21f481bcb51..6ffee5f28d0 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.1 +orjson==3.11.2 packaging>=23.1 paho-mqtt==2.1.0 Pillow==11.3.0 diff --git a/pyproject.toml b/pyproject.toml index c4b4bfcdd7d..1f74056ac91 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.1", + "orjson==3.11.2", "packaging>=23.1", "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", diff --git a/requirements.txt b/requirements.txt index f0f49ac519b..502e1e225cf 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.1 +orjson==3.11.2 packaging>=23.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 From b3d3284f5c275e1ab5da815a8048882de6791f3c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 13 Aug 2025 20:55:22 +0200 Subject: [PATCH 0997/1113] Update types packages (#150586) --- requirements_test.txt | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 2f680240f6e..9df62168b19 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -35,19 +35,19 @@ requests-mock==1.12.1 respx==0.22.0 syrupy==4.9.1 tqdm==4.67.1 -types-aiofiles==24.1.0.20250708 +types-aiofiles==24.1.0.20250809 types-atomicwrites==1.4.5.1 -types-croniter==6.0.0.20250626 +types-croniter==6.0.0.20250809 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.20250703 -types-psutil==7.0.0.20250601 -types-pyserial==3.5.0.20250326 -types-python-dateutil==2.9.0.20250708 +types-pexpect==4.9.0.20250809 +types-protobuf==6.30.2.20250809 +types-psutil==7.0.0.20250801 +types-pyserial==3.5.0.20250809 +types-python-dateutil==2.9.0.20250809 types-python-slugify==8.0.2.20240310 -types-pytz==2025.2.0.20250516 -types-PyYAML==6.0.12.20250516 -types-requests==2.32.4.20250611 +types-pytz==2025.2.0.20250809 +types-PyYAML==6.0.12.20250809 +types-requests==2.32.4.20250809 types-xmltodict==0.13.0.3 From cf68214c4d5472d79b1df8a14a766b8c9d7654f3 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 13 Aug 2025 13:58:57 -0500 Subject: [PATCH 0998/1113] Bump hassil to 3.1.0 (#150584) --- homeassistant/components/assist_satellite/entity.py | 10 +++++----- .../components/assist_satellite/manifest.json | 2 +- homeassistant/components/conversation/default_agent.py | 10 +++++----- 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 +- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index e7a10ef63f6..3d562544c68 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -11,7 +11,7 @@ import time from typing import Any, Literal, final from hassil import Intents, recognize -from hassil.expression import Expression, ListReference, Sequence +from hassil.expression import Expression, Group, ListReference from hassil.intents import WildcardSlotList from homeassistant.components import conversation, media_source, stt, tts @@ -413,7 +413,7 @@ class AssistSatelliteEntity(entity.Entity): for intent in intents.intents.values(): for intent_data in intent.data: for sentence in intent_data.sentences: - _collect_list_references(sentence, wildcard_names) + _collect_list_references(sentence.expression, wildcard_names) for wildcard_name in wildcard_names: intents.slot_lists[wildcard_name] = WildcardSlotList(wildcard_name) @@ -727,9 +727,9 @@ class AssistSatelliteEntity(entity.Entity): def _collect_list_references(expression: Expression, list_names: set[str]) -> None: """Collect list reference names recursively.""" - if isinstance(expression, Sequence): - seq: Sequence = expression - for item in seq.items: + if isinstance(expression, Group): + grp: Group = expression + for item in grp.items: _collect_list_references(item, list_names) elif isinstance(expression, ListReference): # {list} diff --git a/homeassistant/components/assist_satellite/manifest.json b/homeassistant/components/assist_satellite/manifest.json index 97362f157e4..184de576050 100644 --- a/homeassistant/components/assist_satellite/manifest.json +++ b/homeassistant/components/assist_satellite/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/assist_satellite", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["hassil==2.2.3"] + "requirements": ["hassil==3.1.0"] } diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index bed4b4c0dd6..3fb305098e7 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -14,7 +14,7 @@ import re import time from typing import IO, Any, cast -from hassil.expression import Expression, ListReference, Sequence, TextChunk +from hassil.expression import Expression, Group, ListReference, TextChunk from hassil.intents import ( Intents, SlotList, @@ -1183,7 +1183,7 @@ class DefaultAgent(ConversationEntity): for trigger_intent in trigger_intents.intents.values(): for intent_data in trigger_intent.data: for sentence in intent_data.sentences: - _collect_list_references(sentence, wildcard_names) + _collect_list_references(sentence.expression, wildcard_names) for wildcard_name in wildcard_names: trigger_intents.slot_lists[wildcard_name] = WildcardSlotList(wildcard_name) @@ -1520,9 +1520,9 @@ def _get_match_error_response( def _collect_list_references(expression: Expression, list_names: set[str]) -> None: """Collect list reference names recursively.""" - if isinstance(expression, Sequence): - seq: Sequence = expression - for item in seq.items: + if isinstance(expression, Group): + grp: Group = expression + for item in grp.items: _collect_list_references(item, list_names) elif isinstance(expression, ListReference): # {list} diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 31adffad064..e7d096212ba 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.7.30"] + "requirements": ["hassil==3.1.0", "home-assistant-intents==2025.7.30"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6ffee5f28d0..5fa00656e5a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -36,7 +36,7 @@ go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==5.0.1 hass-nabucasa==0.111.2 -hassil==2.2.3 +hassil==3.1.0 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250811.0 home-assistant-intents==2025.7.30 diff --git a/requirements_all.txt b/requirements_all.txt index 7bfc45ae522..220b23cb47e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1140,7 +1140,7 @@ hass-splunk==0.1.1 # homeassistant.components.assist_satellite # homeassistant.components.conversation -hassil==2.2.3 +hassil==3.1.0 # homeassistant.components.jewish_calendar hdate[astral]==1.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3cc54a40ab8..e3f832b464b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -998,7 +998,7 @@ hass-nabucasa==0.111.2 # homeassistant.components.assist_satellite # homeassistant.components.conversation -hassil==2.2.3 +hassil==3.1.0 # homeassistant.components.jewish_calendar hdate[astral]==1.1.2 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 4b8aafce70f..6dbb086f273 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -31,7 +31,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.8.9,source=/uv,target=/bin/uv \ PyTurboJPEG==1.8.0 \ go2rtc-client==0.2.1 \ ha-ffmpeg==3.2.2 \ - hassil==2.2.3 \ + hassil==3.1.0 \ home-assistant-intents==2025.7.30 \ mutagen==1.47.0 \ pymicro-vad==1.0.1 \ From 4f640148168e92e913ecbdd1b5941292e097bd7e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 13 Aug 2025 23:34:12 +0200 Subject: [PATCH 0999/1113] Add wind gust sensor to OpenWeatherMap (#150607) --- .../components/openweathermap/sensor.py | 8 ++ tests/components/openweathermap/conftest.py | 2 +- .../openweathermap/snapshots/test_sensor.ambr | 120 ++++++++++++++++++ .../snapshots/test_weather.ambr | 3 + 4 files changed, 132 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 87b7860afb5..2860abbe64c 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -51,6 +51,7 @@ from .const import ( ATTR_API_WEATHER, ATTR_API_WEATHER_CODE, ATTR_API_WIND_BEARING, + ATTR_API_WIND_GUST, ATTR_API_WIND_SPEED, ATTRIBUTION, DOMAIN, @@ -93,6 +94,13 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, ), + SensorEntityDescription( + key=ATTR_API_WIND_GUST, + name="Wind gust", + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, + ), SensorEntityDescription( key=ATTR_API_WIND_BEARING, name="Wind bearing", diff --git a/tests/components/openweathermap/conftest.py b/tests/components/openweathermap/conftest.py index f7de53b8f97..7c7de776acf 100644 --- a/tests/components/openweathermap/conftest.py +++ b/tests/components/openweathermap/conftest.py @@ -77,8 +77,8 @@ def owm_client_mock() -> Generator[AsyncMock]: cloud_coverage=75, visibility=10000, wind_speed=9.83, + wind_gust=11.81, wind_bearing=199, - wind_gust=None, rain={"1h": 1.21}, snow=None, condition=WeatherCondition( diff --git a/tests/components/openweathermap/snapshots/test_sensor.ambr b/tests/components/openweathermap/snapshots/test_sensor.ambr index 11a1feb721f..de953861f80 100644 --- a/tests/components/openweathermap/snapshots/test_sensor.ambr +++ b/tests/components/openweathermap/snapshots/test_sensor.ambr @@ -1239,6 +1239,66 @@ 'state': '199', }) # --- +# name: test_sensor_states[current][sensor.openweathermap_wind_gust-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_wind_gust', + '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 gust', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-wind_gust', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_wind_gust-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'wind_speed', + 'friendly_name': 'openweathermap Wind gust', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_wind_gust', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.516', + }) +# --- # name: test_sensor_states[current][sensor.openweathermap_wind_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2108,6 +2168,66 @@ 'state': '199', }) # --- +# name: test_sensor_states[v3.0][sensor.openweathermap_wind_gust-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_wind_gust', + '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 gust', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-wind_gust', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_wind_gust-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'wind_speed', + 'friendly_name': 'openweathermap Wind gust', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_wind_gust', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.516', + }) +# --- # name: test_sensor_states[v3.0][sensor.openweathermap_wind_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/openweathermap/snapshots/test_weather.ambr b/tests/components/openweathermap/snapshots/test_weather.ambr index 760160a96f4..073715c87ec 100644 --- a/tests/components/openweathermap/snapshots/test_weather.ambr +++ b/tests/components/openweathermap/snapshots/test_weather.ambr @@ -74,6 +74,7 @@ 'temperature_unit': , 'visibility_unit': , 'wind_bearing': 199, + 'wind_gust_speed': 42.52, 'wind_speed': 35.39, 'wind_speed_unit': , }), @@ -137,6 +138,7 @@ 'temperature_unit': , 'visibility_unit': , 'wind_bearing': 199, + 'wind_gust_speed': 42.52, 'wind_speed': 35.39, 'wind_speed_unit': , }), @@ -200,6 +202,7 @@ 'temperature_unit': , 'visibility_unit': , 'wind_bearing': 199, + 'wind_gust_speed': 42.52, 'wind_speed': 35.39, 'wind_speed_unit': , }), From f58b2177a2a58c699951b05d23f364e6c924a206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Wed, 13 Aug 2025 23:42:47 +0200 Subject: [PATCH 1000/1113] Bump pymiele to 0.5.4 (#150605) --- 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 b8ca0535c3e..63ace343dc8 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.3"], + "requirements": ["pymiele==0.5.4"], "single_config_entry": true, "zeroconf": ["_mieleathome._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 220b23cb47e..05cff194cf4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2140,7 +2140,7 @@ pymeteoclimatic==0.1.0 pymicro-vad==1.0.1 # homeassistant.components.miele -pymiele==0.5.3 +pymiele==0.5.4 # homeassistant.components.xiaomi_tv pymitv==1.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e3f832b464b..70db04edfa1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1782,7 +1782,7 @@ pymeteoclimatic==0.1.0 pymicro-vad==1.0.1 # homeassistant.components.miele -pymiele==0.5.3 +pymiele==0.5.4 # homeassistant.components.mochad pymochad==0.2.0 From b5db0e98b495ecd766244f9a166ce4ac1eda9e37 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Wed, 13 Aug 2025 23:44:07 +0200 Subject: [PATCH 1001/1113] Bump pyenphase to 2.3.0 (#150600) --- 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 e337dac74e0..d9a3dd0bdce 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.3"], + "requirements": ["pyenphase==2.3.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 05cff194cf4..97baa819716 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1960,7 +1960,7 @@ pyegps==0.2.5 pyemoncms==0.1.2 # homeassistant.components.enphase_envoy -pyenphase==2.2.3 +pyenphase==2.3.0 # homeassistant.components.envisalink pyenvisalink==4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 70db04edfa1..961211a8662 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1635,7 +1635,7 @@ pyegps==0.2.5 pyemoncms==0.1.2 # homeassistant.components.enphase_envoy -pyenphase==2.2.3 +pyenphase==2.3.0 # homeassistant.components.everlights pyeverlights==0.1.0 From 999980789121dc08b0e98d5674bd720f8dc78376 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 13 Aug 2025 23:48:20 +0200 Subject: [PATCH 1002/1113] Use OptionsFlowWithReload in coinbase (#150587) --- homeassistant/components/coinbase/__init__.py | 29 ------------------- .../components/coinbase/config_flow.py | 8 +++-- homeassistant/components/coinbase/sensor.py | 17 +++++++++++ tests/components/coinbase/test_config_flow.py | 4 --- 4 files changed, 23 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py index adb6dc48c9c..dca7f774331 100644 --- a/homeassistant/components/coinbase/__init__.py +++ b/homeassistant/components/coinbase/__init__.py @@ -12,7 +12,6 @@ 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 from .const import ( @@ -30,9 +29,7 @@ from .const import ( API_RESOURCE_TYPE, API_V3_ACCOUNT_ID, API_V3_TYPE_VAULT, - CONF_CURRENCIES, CONF_EXCHANGE_BASE, - CONF_EXCHANGE_RATES, ) _LOGGER = logging.getLogger(__name__) @@ -47,9 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: CoinbaseConfigEntry) -> """Set up Coinbase from a config entry.""" instance = await hass.async_add_executor_job(create_and_update_instance, entry) - - entry.async_on_unload(entry.add_update_listener(update_listener)) - entry.runtime_data = instance await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -83,29 +77,6 @@ def create_and_update_instance(entry: CoinbaseConfigEntry) -> CoinbaseData: return instance -async def update_listener( - hass: HomeAssistant, config_entry: CoinbaseConfigEntry -) -> None: - """Handle options update.""" - - await hass.config_entries.async_reload(config_entry.entry_id) - - registry = er.async_get(hass) - entities = er.async_entries_for_config_entry(registry, config_entry.entry_id) - - # Remove orphaned entities - for entity in entities: - currency = entity.unique_id.split("-")[-1] - if ( - "xe" in entity.unique_id - and currency not in config_entry.options.get(CONF_EXCHANGE_RATES, []) - ) or ( - "wallet" in entity.unique_id - and currency not in config_entry.options.get(CONF_CURRENCIES, []) - ): - registry.async_remove(entity.entity_id) - - def get_accounts(client): """Handle paginated accounts.""" response = client.get_accounts() diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index e1dad899d2b..6aad3a81d17 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -10,7 +10,11 @@ from coinbase.rest import RESTClient from coinbase.rest.rest_base import HTTPError 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, CONF_API_TOKEN from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -204,7 +208,7 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN): return OptionsFlowHandler() -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for Coinbase.""" async def async_step_init( diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index f69aed8c386..4dfc744b7fa 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -6,6 +6,7 @@ import logging from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -68,6 +69,22 @@ async def async_setup_entry( CONF_EXCHANGE_PRECISION, CONF_EXCHANGE_PRECISION_DEFAULT ) + # Remove orphaned entities + registry = er.async_get(hass) + existing_entities = er.async_entries_for_config_entry( + registry, config_entry.entry_id + ) + for entity in existing_entities: + currency = entity.unique_id.split("-")[-1] + if ( + "xe" in entity.unique_id + and currency not in config_entry.options.get(CONF_EXCHANGE_RATES, []) + ) or ( + "wallet" in entity.unique_id + and currency not in config_entry.options.get(CONF_CURRENCIES, []) + ): + registry.async_remove(entity.entity_id) + for currency in desired_currencies: _LOGGER.debug( "Attempting to set up %s account sensor", diff --git a/tests/components/coinbase/test_config_flow.py b/tests/components/coinbase/test_config_flow.py index 0dc7fa95ffb..3858df83269 100644 --- a/tests/components/coinbase/test_config_flow.py +++ b/tests/components/coinbase/test_config_flow.py @@ -186,9 +186,6 @@ async def test_option_form(hass: HomeAssistant) -> None: "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(hass) await hass.async_block_till_done() @@ -204,7 +201,6 @@ async def test_option_form(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() - assert len(mock_update_listener.mock_calls) == 1 async def test_form_bad_account_currency(hass: HomeAssistant) -> None: From ed39b18d94429cdf43ba03256b21dac2a2931d2a Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Thu, 14 Aug 2025 06:10:19 +0800 Subject: [PATCH 1003/1113] Add cover platform for switchbot cloud (#148993) --- .../components/switchbot_cloud/__init__.py | 23 + .../switchbot_cloud/binary_sensor.py | 5 + .../components/switchbot_cloud/const.py | 2 + .../components/switchbot_cloud/cover.py | 233 +++++++++ .../components/switchbot_cloud/fan.py | 10 +- .../components/switchbot_cloud/sensor.py | 4 + tests/components/switchbot_cloud/conftest.py | 10 + .../components/switchbot_cloud/test_cover.py | 457 ++++++++++++++++++ 8 files changed, 739 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/switchbot_cloud/cover.py create mode 100644 tests/components/switchbot_cloud/test_cover.py diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index ae3a32997ae..44fbfe0fcf4 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.COVER, Platform.FAN, Platform.LIGHT, Platform.LOCK, @@ -47,6 +48,7 @@ class SwitchbotDevices: ) buttons: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) climates: list[tuple[Remote, SwitchBotCoordinator]] = field(default_factory=list) + covers: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) switches: list[tuple[Device | Remote, SwitchBotCoordinator]] = field( default_factory=list ) @@ -192,6 +194,27 @@ 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 [ + "Curtain", + "Curtain3", + "Roller Shade", + "Blind Tilt", + ]: + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.covers.append((device, coordinator)) + devices_data.binary_sensors.append((device, coordinator)) + devices_data.sensors.append((device, coordinator)) + + if isinstance(device, Device) and device.device_type in [ + "Garage Door Opener", + ]: + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.covers.append((device, coordinator)) + devices_data.binary_sensors.append((device, coordinator)) if isinstance(device, Device) and device.device_type in [ "Strip Light", diff --git a/homeassistant/components/switchbot_cloud/binary_sensor.py b/homeassistant/components/switchbot_cloud/binary_sensor.py index cd0e6e8968c..a1ad6d6887d 100644 --- a/homeassistant/components/switchbot_cloud/binary_sensor.py +++ b/homeassistant/components/switchbot_cloud/binary_sensor.py @@ -60,6 +60,11 @@ BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { CALIBRATION_DESCRIPTION, DOOR_OPEN_DESCRIPTION, ), + "Curtain": (CALIBRATION_DESCRIPTION,), + "Curtain3": (CALIBRATION_DESCRIPTION,), + "Roller Shade": (CALIBRATION_DESCRIPTION,), + "Blind Tilt": (CALIBRATION_DESCRIPTION,), + "Garage Door Opener": (DOOR_OPEN_DESCRIPTION,), } diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py index dcca5119a74..a9b3d0df412 100644 --- a/homeassistant/components/switchbot_cloud/const.py +++ b/homeassistant/components/switchbot_cloud/const.py @@ -17,3 +17,5 @@ VACUUM_FAN_SPEED_STRONG = "strong" VACUUM_FAN_SPEED_MAX = "max" AFTER_COMMAND_REFRESH = 5 + +COVER_ENTITY_AFTER_COMMAND_REFRESH = 10 diff --git a/homeassistant/components/switchbot_cloud/cover.py b/homeassistant/components/switchbot_cloud/cover.py new file mode 100644 index 00000000000..77f0b960d25 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/cover.py @@ -0,0 +1,233 @@ +"""Support for the Switchbot BlindTilt, Curtain, Curtain3, RollerShade as Cover.""" + +import asyncio +from typing import Any + +from switchbot_api import ( + BlindTiltCommands, + CommonCommands, + CurtainCommands, + Device, + Remote, + RollerShadeCommands, + SwitchBotAPI, +) + +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +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 COVER_ENTITY_AFTER_COMMAND_REFRESH, 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( + _async_make_entity(data.api, device, coordinator) + for device, coordinator in data.devices.covers + ) + + +class SwitchBotCloudCover(SwitchBotCloudEntity, CoverEntity): + """Representation of a SwitchBot Cover.""" + + _attr_name = None + _attr_is_closed: bool | None = None + + def _set_attributes(self) -> None: + if self.coordinator.data is None: + return + position: int | None = self.coordinator.data.get("slidePosition") + if position is None: + return + self._attr_current_cover_position = 100 - position + self._attr_current_cover_tilt_position = 100 - position + self._attr_is_closed = position == 100 + + +class SwitchBotCloudCoverCurtain(SwitchBotCloudCover): + """Representation of a SwitchBot Curtain & Curtain3.""" + + _attr_device_class = CoverDeviceClass.CURTAIN + _attr_supported_features: CoverEntityFeature = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self.send_api_command(CommonCommands.ON) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self.send_api_command(CommonCommands.OFF) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + position: int | None = kwargs.get("position") + if position is not None: + await self.send_api_command( + CurtainCommands.SET_POSITION, + parameters=f"{0},ff,{100 - position}", + ) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + await self.send_api_command(CurtainCommands.PAUSE) + await self.coordinator.async_request_refresh() + + +class SwitchBotCloudCoverRollerShade(SwitchBotCloudCover): + """Representation of a SwitchBot RollerShade.""" + + _attr_device_class = CoverDeviceClass.SHADE + _attr_supported_features: CoverEntityFeature = ( + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self.send_api_command(RollerShadeCommands.SET_POSITION, parameters=str(0)) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self.send_api_command( + RollerShadeCommands.SET_POSITION, parameters=str(100) + ) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + position: int | None = kwargs.get("position") + if position is not None: + await self.send_api_command( + RollerShadeCommands.SET_POSITION, parameters=str(100 - position) + ) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + +class SwitchBotCloudCoverBlindTilt(SwitchBotCloudCover): + """Representation of a SwitchBot Blind Tilt.""" + + _attr_direction: str | None = None + _attr_device_class = CoverDeviceClass.BLIND + _attr_supported_features: CoverEntityFeature = ( + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + ) + + def _set_attributes(self) -> None: + if self.coordinator.data is None: + return + position: int | None = self.coordinator.data.get("slidePosition") + if position is None: + return + self._attr_is_closed = position in [0, 100] + if position > 50: + percent = 100 - ((position - 50) * 2) + else: + percent = 100 - (50 - position) * 2 + self._attr_current_cover_position = percent + self._attr_current_cover_tilt_position = percent + direction = self.coordinator.data.get("direction") + self._attr_direction = direction.lower() if direction else None + + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + percent: int | None = kwargs.get("tilt_position") + if percent is not None: + await self.send_api_command( + BlindTiltCommands.SET_POSITION, + parameters=f"{self._attr_direction};{percent}", + ) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover.""" + await self.send_api_command(BlindTiltCommands.FULLY_OPEN) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover.""" + if self._attr_direction is not None: + if "up" in self._attr_direction: + await self.send_api_command(BlindTiltCommands.CLOSE_UP) + else: + await self.send_api_command(BlindTiltCommands.CLOSE_DOWN) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + +class SwitchBotCloudCoverGarageDoorOpener(SwitchBotCloudCover): + """Representation of a SwitchBot Garage Door Opener.""" + + _attr_device_class = CoverDeviceClass.GARAGE + _attr_supported_features: CoverEntityFeature = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) + + def _set_attributes(self) -> None: + if self.coordinator.data is None: + return + door_status: int | None = self.coordinator.data.get("doorStatus") + self._attr_is_closed = None if door_status is None else door_status == 1 + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self.send_api_command(CommonCommands.ON) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self.send_api_command(CommonCommands.OFF) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + +@callback +def _async_make_entity( + api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator +) -> ( + SwitchBotCloudCoverBlindTilt + | SwitchBotCloudCoverRollerShade + | SwitchBotCloudCoverCurtain + | SwitchBotCloudCoverGarageDoorOpener +): + """Make a SwitchBotCloudCover device.""" + if device.device_type == "Blind Tilt": + return SwitchBotCloudCoverBlindTilt(api, device, coordinator) + if device.device_type == "Roller Shade": + return SwitchBotCloudCoverRollerShade(api, device, coordinator) + if device.device_type == "Garage Door Opener": + return SwitchBotCloudCoverGarageDoorOpener(api, device, coordinator) + return SwitchBotCloudCoverCurtain(api, device, coordinator) diff --git a/homeassistant/components/switchbot_cloud/fan.py b/homeassistant/components/switchbot_cloud/fan.py index d7cf82520ec..418296ffb55 100644 --- a/homeassistant/components/switchbot_cloud/fan.py +++ b/homeassistant/components/switchbot_cloud/fan.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SwitchbotCloudData -from .const import DOMAIN +from .const import AFTER_COMMAND_REFRESH, DOMAIN from .entity import SwitchBotCloudEntity @@ -88,13 +88,13 @@ class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity): command=BatteryCirculatorFanCommands.SET_WIND_SPEED, parameters=str(self.percentage), ) - await asyncio.sleep(5) + await asyncio.sleep(AFTER_COMMAND_REFRESH) 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 asyncio.sleep(AFTER_COMMAND_REFRESH) await self.coordinator.async_request_refresh() async def async_set_percentage(self, percentage: int) -> None: @@ -107,7 +107,7 @@ class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity): command=BatteryCirculatorFanCommands.SET_WIND_SPEED, parameters=str(percentage), ) - await asyncio.sleep(5) + await asyncio.sleep(AFTER_COMMAND_REFRESH) await self.coordinator.async_request_refresh() async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -116,5 +116,5 @@ class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity): command=BatteryCirculatorFanCommands.SET_WIND_MODE, parameters=preset_mode, ) - await asyncio.sleep(5) + await asyncio.sleep(AFTER_COMMAND_REFRESH) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index 75e994b484e..163b1653686 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -139,6 +139,10 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { "Smart Lock Lite": (BATTERY_DESCRIPTION,), "Smart Lock Pro": (BATTERY_DESCRIPTION,), "Smart Lock Ultra": (BATTERY_DESCRIPTION,), + "Curtain": (BATTERY_DESCRIPTION,), + "Curtain3": (BATTERY_DESCRIPTION,), + "Roller Shade": (BATTERY_DESCRIPTION,), + "Blind Tilt": (BATTERY_DESCRIPTION,), } diff --git a/tests/components/switchbot_cloud/conftest.py b/tests/components/switchbot_cloud/conftest.py index 27214fde28d..c38e3e1264e 100644 --- a/tests/components/switchbot_cloud/conftest.py +++ b/tests/components/switchbot_cloud/conftest.py @@ -39,3 +39,13 @@ def mock_after_command_refresh(): "homeassistant.components.switchbot_cloud.const.AFTER_COMMAND_REFRESH", 0 ): yield + + +@pytest.fixture(scope="package", autouse=True) +def mock_after_command_refresh_for_cover(): + """Mock after command refresh.""" + with patch( + "homeassistant.components.switchbot_cloud.const.COVER_ENTITY_AFTER_COMMAND_REFRESH", + 0, + ): + yield diff --git a/tests/components/switchbot_cloud/test_cover.py b/tests/components/switchbot_cloud/test_cover.py new file mode 100644 index 00000000000..0d0daf1bd7b --- /dev/null +++ b/tests/components/switchbot_cloud/test_cover.py @@ -0,0 +1,457 @@ +"""Test for the switchbot_cloud Cover.""" + +from unittest.mock import patch + +import pytest +from switchbot_api import ( + BlindTiltCommands, + CommonCommands, + CurtainCommands, + Device, + RollerShadeCommands, +) + +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN +from homeassistant.components.switchbot_cloud import SwitchBotAPI +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, + SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, + STATE_CLOSED, + STATE_OPEN, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant + +from . import configure_integration + + +async def test_cover_set_attributes_normal( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test cover set_attributes normal.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Roller Shade", + hubDeviceId="test-hub-id", + ), + ] + + cover_id = "cover.cover_1" + mock_get_status.return_value = {"slidePosition": 100, "direction": "up"} + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_CLOSED + + +@pytest.mark.parametrize( + "device_model", + [ + "Roller Shade", + "Blind Tilt", + ], +) +async def test_cover_set_attributes_position_is_none( + hass: HomeAssistant, mock_list_devices, mock_get_status, device_model +) -> None: + """Test cover_set_attributes position is none.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType=device_model, + hubDeviceId="test-hub-id", + ), + ] + + cover_id = "cover.cover_1" + mock_get_status.side_effect = [{"direction": "up"}, {"direction": "up"}] + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "device_model", + [ + "Roller Shade", + "Blind Tilt", + ], +) +async def test_cover_set_attributes_coordinator_is_none( + hass: HomeAssistant, mock_list_devices, mock_get_status, device_model +) -> None: + """Test cover set_attributes coordinator is none.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType=device_model, + hubDeviceId="test-hub-id", + ), + ] + + cover_id = "cover.cover_1" + mock_get_status.return_value = None + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_UNKNOWN + + +async def test_curtain_features( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test curtain features.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Curtain", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + { + "slidePosition": 95, + }, + { + "slidePosition": 95, + }, + { + "slidePosition": 95, + }, + { + "slidePosition": 95, + }, + { + "slidePosition": 95, + }, + { + "slidePosition": 95, + }, + { + "slidePosition": 95, + }, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + cover_id = "cover.cover_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", CommonCommands.ON, "command", "default" + ) + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", CommonCommands.OFF, "command", "default" + ) + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", CurtainCommands.PAUSE, "command", "default" + ) + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {"position": 50, ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", CurtainCommands.SET_POSITION, "command", "0,ff,50" + ) + + +async def test_blind_tilt_features( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test blind_tilt features.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Blind Tilt", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + {"slidePosition": 95, "direction": "up"}, + {"slidePosition": 95, "direction": "up"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + cover_id = "cover.cover_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", BlindTiltCommands.FULLY_OPEN, "command", "default" + ) + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", BlindTiltCommands.CLOSE_UP, "command", "default" + ) + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {"tilt_position": 25, ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", BlindTiltCommands.SET_POSITION, "command", "up;25" + ) + + +async def test_blind_tilt_features_close_down( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test blind tilt features close_down.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Blind Tilt", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + {"slidePosition": 25, "direction": "down"}, + {"slidePosition": 25, "direction": "down"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + cover_id = "cover.cover_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", BlindTiltCommands.CLOSE_DOWN, "command", "default" + ) + + +async def test_roller_shade_features( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test roller shade features.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Roller Shade", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + { + "slidePosition": 95, + }, + { + "slidePosition": 95, + }, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + cover_id = "cover.cover_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", RollerShadeCommands.SET_POSITION, "command", "0" + ) + + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_OPEN + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", RollerShadeCommands.SET_POSITION, "command", "100" + ) + + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_OPEN + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {"position": 50, ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", RollerShadeCommands.SET_POSITION, "command", "50" + ) + + +async def test_cover_set_attributes_coordinator_is_none_for_garage_door( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test cover set_attributes coordinator is none for garage_door.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Garage Door Opener", + hubDeviceId="test-hub-id", + ), + ] + cover_id = "cover.cover_1" + mock_get_status.return_value = None + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_UNKNOWN + + +async def test_garage_door_features_close( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test garage door features close.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Garage Door Opener", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + { + "doorStatus": 1, + }, + { + "doorStatus": 1, + }, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + cover_id = "cover.cover_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", CommonCommands.OFF, "command", "default" + ) + + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_CLOSED + + +async def test_garage_door_features_open( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test garage_door features open cover.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Garage Door Opener", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + { + "doorStatus": 0, + }, + { + "doorStatus": 0, + }, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + cover_id = "cover.cover_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", CommonCommands.ON, "command", "default" + ) + + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_OPEN From 12706178c2e324a8f4a1649047e07b507f3129f9 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 13 Aug 2025 18:11:52 -0400 Subject: [PATCH 1004/1113] Change Snoo to use MQTT instead of PubNub (#150570) --- homeassistant/components/snoo/coordinator.py | 2 +- tests/components/snoo/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/snoo/coordinator.py b/homeassistant/components/snoo/coordinator.py index 8ce0db34621..43e717c2bc7 100644 --- a/homeassistant/components/snoo/coordinator.py +++ b/homeassistant/components/snoo/coordinator.py @@ -40,7 +40,7 @@ class SnooCoordinator(DataUpdateCoordinator[SnooData]): async def setup(self) -> None: """Perform setup needed on every coordintaor creation.""" - await self.snoo.subscribe(self.device, self.async_set_updated_data) + self.snoo.start_subscribe(self.device, self.async_set_updated_data) # After we subscribe - get the status so that we have something to start with. # We only need to do this once. The device will auto update otherwise. await self.snoo.get_status(self.device) diff --git a/tests/components/snoo/__init__.py b/tests/components/snoo/__init__.py index b4692e6f08b..417eb438143 100644 --- a/tests/components/snoo/__init__.py +++ b/tests/components/snoo/__init__.py @@ -48,7 +48,7 @@ def find_update_callback( mock: AsyncMock, serial_number: str ) -> Callable[[SnooData], Awaitable[None]]: """Find the update callback for a specific identifier.""" - for call in mock.subscribe.call_args_list: + for call in mock.start_subscribe.call_args_list: if call[0][0].serialNumber == serial_number: return call[0][1] pytest.fail(f"Callback for identifier {serial_number} not found") From 6a4bf4ec72a52f965e9febcd7589a56548b1faa1 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 13 Aug 2025 18:12:18 -0400 Subject: [PATCH 1005/1113] Bump python-snoo to 0.8.2 (#150569) --- 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 0db11c5b086..0a2301c6fd8 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.8.1"] + "requirements": ["python-snoo==0.8.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 97baa819716..e727c52bbfb 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.8.1 +python-snoo==0.8.2 # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 961211a8662..bb37d13404e 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.8.1 +python-snoo==0.8.2 # homeassistant.components.songpal python-songpal==0.16.2 From f28e9f60ee8ceb2b238a374f3f42f2ab9fe46b2f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 14 Aug 2025 01:03:04 +0200 Subject: [PATCH 1006/1113] Use runtime_data in pvpc_hourly_pricing (#150565) --- .../components/pvpc_hourly_pricing/__init__.py | 18 +++++++----------- .../pvpc_hourly_pricing/coordinator.py | 6 ++++-- .../components/pvpc_hourly_pricing/sensor.py | 7 +++---- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/pvpc_hourly_pricing/__init__.py b/homeassistant/components/pvpc_hourly_pricing/__init__.py index 4d120e9fae7..ad35e409627 100644 --- a/homeassistant/components/pvpc_hourly_pricing/__init__.py +++ b/homeassistant/components/pvpc_hourly_pricing/__init__.py @@ -1,18 +1,17 @@ """The pvpc_hourly_pricing integration to collect Spain official electric prices.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .const import ATTR_POWER, ATTR_POWER_P3, DOMAIN -from .coordinator import ElecPricesDataUpdateCoordinator +from .const import ATTR_POWER, ATTR_POWER_P3 +from .coordinator import ElecPricesDataUpdateCoordinator, PVPCConfigEntry from .helpers import get_enabled_sensor_keys PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: PVPCConfigEntry) -> bool: """Set up pvpc hourly pricing from a config entry.""" entity_registry = er.async_get(hass) sensor_keys = get_enabled_sensor_keys( @@ -22,13 +21,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = ElecPricesDataUpdateCoordinator(hass, entry, sensor_keys) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(async_update_options)) return True -async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_update_options(hass: HomeAssistant, entry: PVPCConfigEntry) -> None: """Handle options update.""" if any( entry.data.get(attrib) != entry.options.get(attrib) @@ -41,9 +40,6 @@ async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: PVPCConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - 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/pvpc_hourly_pricing/coordinator.py b/homeassistant/components/pvpc_hourly_pricing/coordinator.py index 28e676d37ed..bc9d6a21557 100644 --- a/homeassistant/components/pvpc_hourly_pricing/coordinator.py +++ b/homeassistant/components/pvpc_hourly_pricing/coordinator.py @@ -17,14 +17,16 @@ from .const import ATTR_POWER, ATTR_POWER_P3, ATTR_TARIFF, DOMAIN _LOGGER = logging.getLogger(__name__) +type PVPCConfigEntry = ConfigEntry[ElecPricesDataUpdateCoordinator] + class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): """Class to manage fetching Electricity prices data from API.""" - config_entry: ConfigEntry + config_entry: PVPCConfigEntry def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, sensor_keys: set[str] + self, hass: HomeAssistant, entry: PVPCConfigEntry, sensor_keys: set[str] ) -> None: """Initialize.""" self.api = PVPCData( diff --git a/homeassistant/components/pvpc_hourly_pricing/sensor.py b/homeassistant/components/pvpc_hourly_pricing/sensor.py index 1b92cfc533d..c49756290ab 100644 --- a/homeassistant/components/pvpc_hourly_pricing/sensor.py +++ b/homeassistant/components/pvpc_hourly_pricing/sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CURRENCY_EURO, UnitOfEnergy from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -24,7 +23,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import ElecPricesDataUpdateCoordinator +from .coordinator import ElecPricesDataUpdateCoordinator, PVPCConfigEntry from .helpers import make_sensor_unique_id _LOGGER = logging.getLogger(__name__) @@ -149,11 +148,11 @@ _PRICE_SENSOR_ATTRIBUTES_MAP = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PVPCConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the electricity price sensor from config_entry.""" - coordinator: ElecPricesDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data sensors = [ElecPriceSensor(coordinator, SENSOR_TYPES[0], entry.unique_id)] if coordinator.api.using_private_api: sensors.extend( From 4954c2a84be8b015d3f61c3183f287b44528db16 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 14 Aug 2025 09:27:18 +0200 Subject: [PATCH 1007/1113] Bump actions/ai-inference from 1.2.8 to 2.0.0 (#150619) 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 17777f576de..5f9522e0593 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.8 + uses: actions/ai-inference@v2.0.0 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 1aa51492c74..bcad5726968 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.8 + uses: actions/ai-inference@v2.0.0 with: model: openai/gpt-4o-mini system-prompt: | From 5a789cbbc8710df8b29bbef3372e4543550c6f98 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 14 Aug 2025 09:30:02 +0200 Subject: [PATCH 1008/1113] Bump togrill to 0.7.0 in preperation for number (#150611) --- homeassistant/components/togrill/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/togrill/manifest.json b/homeassistant/components/togrill/manifest.json index 7d777b8ae67..9f9ad8c3782 100644 --- a/homeassistant/components/togrill/manifest.json +++ b/homeassistant/components/togrill/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/togrill", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["togrill-bluetooth==0.4.0"] + "requirements": ["togrill-bluetooth==0.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e727c52bbfb..0083e03c4ca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2953,7 +2953,7 @@ tmb==0.0.4 todoist-api-python==2.1.7 # homeassistant.components.togrill -togrill-bluetooth==0.4.0 +togrill-bluetooth==0.7.0 # homeassistant.components.tolo tololib==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bb37d13404e..a5b5379d964 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2430,7 +2430,7 @@ tilt-pi==0.2.1 todoist-api-python==2.1.7 # homeassistant.components.togrill -togrill-bluetooth==0.4.0 +togrill-bluetooth==0.7.0 # homeassistant.components.tolo tololib==1.2.2 From bb3d5718872ef18f62987e8a9ed2fd844ce8dbe2 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 14 Aug 2025 09:30:47 +0200 Subject: [PATCH 1009/1113] Make sure we update the api version in philips_js discovery (#150604) --- homeassistant/components/philips_js/config_flow.py | 2 +- tests/components/philips_js/test_config_flow.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/philips_js/config_flow.py b/homeassistant/components/philips_js/config_flow.py index a568d51e5ea..779452b284b 100644 --- a/homeassistant/components/philips_js/config_flow.py +++ b/homeassistant/components/philips_js/config_flow.py @@ -82,7 +82,7 @@ class PhilipsJSConfigFlow(ConfigFlow, domain=DOMAIN): ) await hub.getSystem() - await hub.setTransport(hub.secured_transport) + await hub.setTransport(hub.secured_transport, hub.api_version_detected) if not hub.system or not hub.name: raise ConnectionFailure("System data or name is empty") diff --git a/tests/components/philips_js/test_config_flow.py b/tests/components/philips_js/test_config_flow.py index c4dcc44e619..77227fd0f63 100644 --- a/tests/components/philips_js/test_config_flow.py +++ b/tests/components/philips_js/test_config_flow.py @@ -125,7 +125,7 @@ async def test_pairing(hass: HomeAssistant, mock_tv_pairable, mock_setup_entry) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - mock_tv.setTransport.assert_called_with(True) + mock_tv.setTransport.assert_called_with(True, ANY) mock_tv.pairRequest.assert_called() result = await hass.config_entries.flow.async_configure( @@ -204,7 +204,7 @@ async def test_pair_grant_failed( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - mock_tv.setTransport.assert_called_with(True) + mock_tv.setTransport.assert_called_with(True, ANY) mock_tv.pairRequest.assert_called() # Test with invalid pin @@ -266,6 +266,7 @@ async def test_zeroconf_discovery( """Test we can setup from zeroconf discovery.""" mock_tv_pairable.secured_transport = secured_transport + mock_tv_pairable.api_version_detected = 6 result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, @@ -291,7 +292,7 @@ async def test_zeroconf_discovery( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - mock_tv_pairable.setTransport.assert_called_with(secured_transport) + mock_tv_pairable.setTransport.assert_called_with(secured_transport, 6) mock_tv_pairable.pairRequest.assert_called() From 7e28e3dcd303ba711d50815d118f9647e9272245 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 14 Aug 2025 09:31:43 +0200 Subject: [PATCH 1010/1113] Add sw_version to JustNimbus device (#150592) --- homeassistant/components/justnimbus/entity.py | 1 + tests/components/justnimbus/test_config_flow.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/justnimbus/entity.py b/homeassistant/components/justnimbus/entity.py index f85c3f33f93..1d0e6a4c1bc 100644 --- a/homeassistant/components/justnimbus/entity.py +++ b/homeassistant/components/justnimbus/entity.py @@ -28,6 +28,7 @@ class JustNimbusEntity( identifiers={(DOMAIN, device_id)}, name="JustNimbus Sensor", manufacturer="JustNimbus", + sw_version=coordinator.data.api_version, ) @property diff --git a/tests/components/justnimbus/test_config_flow.py b/tests/components/justnimbus/test_config_flow.py index cc3a7a88285..d581f230dde 100644 --- a/tests/components/justnimbus/test_config_flow.py +++ b/tests/components/justnimbus/test_config_flow.py @@ -132,7 +132,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: with patch( "homeassistant.components.justnimbus.config_flow.justnimbus.JustNimbusClient.get_data", - return_value=MagicMock(), + return_value=MagicMock(api_version="1.0.0"), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], From cc4b9e0eca2436be9ae989e87edb19bbb327651a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Thu, 14 Aug 2025 11:46:06 +0200 Subject: [PATCH 1011/1113] Extend UnitOfReactivePower with 'mvar' (#150415) --- homeassistant/components/number/const.py | 2 +- .../components/recorder/statistics.py | 2 + .../components/recorder/websocket_api.py | 2 + homeassistant/components/sensor/const.py | 4 +- homeassistant/const.py | 1 + homeassistant/util/unit_conversion.py | 17 ++++++ tests/components/sensor/test_init.py | 1 - tests/util/test_unit_conversion.py | 60 +++++++++++++++---- 8 files changed, 73 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 373814cae9a..02e11d1530a 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -338,7 +338,7 @@ class NumberDeviceClass(StrEnum): REACTIVE_POWER = "reactive_power" """Reactive power. - Unit of measurement: `var`, `kvar` + Unit of measurement: `mvar`, `var`, `kvar` """ SIGNAL_STRENGTH = "signal_strength" diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 20fd1a3ce28..2321da45bb9 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -60,6 +60,7 @@ from homeassistant.util.unit_conversion import ( PowerConverter, PressureConverter, ReactiveEnergyConverter, + ReactivePowerConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -216,6 +217,7 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { **dict.fromkeys(PowerConverter.VALID_UNITS, PowerConverter), **dict.fromkeys(PressureConverter.VALID_UNITS, PressureConverter), **dict.fromkeys(ReactiveEnergyConverter.VALID_UNITS, ReactiveEnergyConverter), + **dict.fromkeys(ReactivePowerConverter.VALID_UNITS, ReactivePowerConverter), **dict.fromkeys(SpeedConverter.VALID_UNITS, SpeedConverter), **dict.fromkeys(TemperatureConverter.VALID_UNITS, TemperatureConverter), **dict.fromkeys(UnitlessRatioConverter.VALID_UNITS, UnitlessRatioConverter), diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 310e2fc85c5..4f798fb86d0 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -33,6 +33,7 @@ from homeassistant.util.unit_conversion import ( PowerConverter, PressureConverter, ReactiveEnergyConverter, + ReactivePowerConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -81,6 +82,7 @@ UNIT_SCHEMA = vol.Schema( vol.Optional("power"): vol.In(PowerConverter.VALID_UNITS), vol.Optional("pressure"): vol.In(PressureConverter.VALID_UNITS), vol.Optional("reactive_energy"): vol.In(ReactiveEnergyConverter.VALID_UNITS), + vol.Optional("reactive_power"): vol.In(ReactivePowerConverter.VALID_UNITS), vol.Optional("speed"): vol.In(SpeedConverter.VALID_UNITS), vol.Optional("temperature"): vol.In(TemperatureConverter.VALID_UNITS), vol.Optional("unitless"): vol.In(UnitlessRatioConverter.VALID_UNITS), diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 251a233e1fa..92607ba07eb 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -64,6 +64,7 @@ from homeassistant.util.unit_conversion import ( PowerConverter, PressureConverter, ReactiveEnergyConverter, + ReactivePowerConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -370,7 +371,7 @@ class SensorDeviceClass(StrEnum): REACTIVE_POWER = "reactive_power" """Reactive power. - Unit of measurement: `var`, `kvar` + Unit of measurement: `mvar`, `var`, `kvar` """ SIGNAL_STRENGTH = "signal_strength" @@ -550,6 +551,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.PRECIPITATION_INTENSITY: SpeedConverter, SensorDeviceClass.PRESSURE: PressureConverter, SensorDeviceClass.REACTIVE_ENERGY: ReactiveEnergyConverter, + SensorDeviceClass.REACTIVE_POWER: ReactivePowerConverter, SensorDeviceClass.SPEED: SpeedConverter, SensorDeviceClass.TEMPERATURE: TemperatureConverter, SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: MassVolumeConcentrationConverter, diff --git a/homeassistant/const.py b/homeassistant/const.py index 8e340d8468b..b74fa64d5c7 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -609,6 +609,7 @@ class UnitOfPower(StrEnum): class UnitOfReactivePower(StrEnum): """Reactive power units.""" + MILLIVOLT_AMPERE_REACTIVE = "mvar" VOLT_AMPERE_REACTIVE = "var" KILO_VOLT_AMPERE_REACTIVE = "kvar" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 610cf5db7a9..ad459e55d15 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -29,6 +29,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfPressure, UnitOfReactiveEnergy, + UnitOfReactivePower, UnitOfSpeed, UnitOfTemperature, UnitOfTime, @@ -460,6 +461,22 @@ class ReactiveEnergyConverter(BaseUnitConverter): VALID_UNITS = set(UnitOfReactiveEnergy) +class ReactivePowerConverter(BaseUnitConverter): + """Utility to convert reactive power values.""" + + UNIT_CLASS = "reactive_power" + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE: 1 * 1000, + UnitOfReactivePower.VOLT_AMPERE_REACTIVE: 1, + UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE: 1 / 1000, + } + VALID_UNITS = { + UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE, + UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + } + + class SpeedConverter(BaseUnitConverter): """Utility to convert speed values.""" diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index ce21f6ea8ab..62141186b55 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -2978,7 +2978,6 @@ def test_device_class_converters_are_complete() -> None: SensorDeviceClass.PM1, SensorDeviceClass.PM10, SensorDeviceClass.PM25, - SensorDeviceClass.REACTIVE_POWER, SensorDeviceClass.SIGNAL_STRENGTH, SensorDeviceClass.SOUND_PRESSURE, SensorDeviceClass.SULPHUR_DIOXIDE, diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 1ef66584952..08fb7cce067 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -29,6 +29,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfPressure, UnitOfReactiveEnergy, + UnitOfReactivePower, UnitOfSpeed, UnitOfTemperature, UnitOfTime, @@ -57,6 +58,7 @@ from homeassistant.util.unit_conversion import ( PowerConverter, PressureConverter, ReactiveEnergyConverter, + ReactivePowerConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -89,6 +91,7 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { PowerConverter, PressureConverter, ReactiveEnergyConverter, + ReactivePowerConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -100,6 +103,11 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { # Dict containing all converters with a corresponding unit ratio. _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, float]] = { + ApparentPowerConverter: ( + UnitOfApparentPower.MILLIVOLT_AMPERE, + UnitOfApparentPower.VOLT_AMPERE, + 1000, + ), AreaConverter: (UnitOfArea.SQUARE_KILOMETERS, UnitOfArea.SQUARE_METERS, 0.000001), BloodGlucoseConcentrationConverter: ( UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER, @@ -141,11 +149,6 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, 1000, ), - ApparentPowerConverter: ( - UnitOfApparentPower.MILLIVOLT_AMPERE, - UnitOfApparentPower.VOLT_AMPERE, - 1000, - ), PowerConverter: (UnitOfPower.WATT, UnitOfPower.KILO_WATT, 1000), PressureConverter: (UnitOfPressure.HPA, UnitOfPressure.INHG, 33.86389), ReactiveEnergyConverter: ( @@ -153,6 +156,11 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, 1000, ), + ReactivePowerConverter: ( + UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE, + UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + 1000, + ), SpeedConverter: ( UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.MILES_PER_HOUR, @@ -176,6 +184,14 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo _CONVERTED_VALUE: dict[ type[BaseUnitConverter], list[tuple[float, str | None, float, str | None]] ] = { + ApparentPowerConverter: [ + ( + 10, + UnitOfApparentPower.MILLIVOLT_AMPERE, + 0.01, + UnitOfApparentPower.VOLT_AMPERE, + ), + ], AreaConverter: [ # Square Meters to other units (5, UnitOfArea.SQUARE_METERS, 50000, UnitOfArea.SQUARE_CENTIMETERS), @@ -623,14 +639,6 @@ _CONVERTED_VALUE: dict[ (1, UnitOfMass.STONES, 14, UnitOfMass.POUNDS), (1, UnitOfMass.STONES, 224, UnitOfMass.OUNCES), ], - ApparentPowerConverter: [ - ( - 10, - UnitOfApparentPower.MILLIVOLT_AMPERE, - 0.01, - UnitOfApparentPower.VOLT_AMPERE, - ), - ], PowerConverter: [ (10, UnitOfPower.KILO_WATT, 10000, UnitOfPower.WATT), (10, UnitOfPower.MEGA_WATT, 10e6, UnitOfPower.WATT), @@ -682,6 +690,32 @@ _CONVERTED_VALUE: dict[ UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, ), ], + ReactivePowerConverter: [ + ( + 10, + UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + 10000, + UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + ), + ( + 10, + UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + 0.01, + UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + ), + ( + 10, + UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE, + 0.01, + UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + ), + ( + 10, + UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE, + 0.00001, + UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + ), + ], SpeedConverter: [ # 5 km/h / 1.609 km/mi = 3.10686 mi/h (5, UnitOfSpeed.KILOMETERS_PER_HOUR, 3.106856, UnitOfSpeed.MILES_PER_HOUR), From 02dca5f0ad298a52818b6559ed17ce3c0ed075b6 Mon Sep 17 00:00:00 2001 From: Martin Dybal Date: Thu, 14 Aug 2025 12:55:54 +0200 Subject: [PATCH 1012/1113] Fix type annotation for climate `_attr_current_humidity` (#150615) --- homeassistant/components/climate/__init__.py | 2 +- homeassistant/components/lookin/climate.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 790579d6a73..4a244ce3530 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -255,7 +255,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ) entity_description: ClimateEntityDescription - _attr_current_humidity: int | None = None + _attr_current_humidity: float | None = None _attr_current_temperature: float | None = None _attr_fan_mode: str | None _attr_fan_modes: list[str] | None diff --git a/homeassistant/components/lookin/climate.py b/homeassistant/components/lookin/climate.py index 6b92032e4ab..cc9634ac1b6 100644 --- a/homeassistant/components/lookin/climate.py +++ b/homeassistant/components/lookin/climate.py @@ -91,7 +91,7 @@ async def async_setup_entry( class ConditionerEntity(LookinCoordinatorEntity, ClimateEntity): """An aircon or heat pump.""" - _attr_current_humidity: float | None = None # type: ignore[assignment] + _attr_current_humidity: float | None = None _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE From e6103fdcf4b5634a899daef9cb4652b5e4a99208 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 14 Aug 2025 13:43:32 +0200 Subject: [PATCH 1013/1113] Bump airOS to 0.2.11 (#150627) --- 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 58f76abe577..16855d805c0 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.8"] + "requirements": ["airos==0.2.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0083e03c4ca..2b4c6c916bb 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.8 +airos==0.2.11 # homeassistant.components.airthings_ble airthings-ble==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a5b5379d964..7e11345951b 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.8 +airos==0.2.11 # homeassistant.components.airthings_ble airthings-ble==0.9.2 From 5358c89bfdac9a1644b298e83accd21e578033c1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 14 Aug 2025 14:51:20 +0200 Subject: [PATCH 1014/1113] Add fixtures for one door refrigerator in SmartThings (#150632) --- tests/components/smartthings/conftest.py | 1 + .../da_ref_normal_01011_onedoor.json | 1380 +++++++++++++++++ .../devices/da_ref_normal_01011_onedoor.json | 588 +++++++ .../snapshots/test_binary_sensor.ambr | 49 + .../smartthings/snapshots/test_init.ambr | 31 + .../smartthings/snapshots/test_sensor.ambr | 282 ++++ .../smartthings/snapshots/test_switch.ambr | 48 + 7 files changed, 2379 insertions(+) create mode 100644 tests/components/smartthings/fixtures/device_status/da_ref_normal_01011_onedoor.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ref_normal_01011_onedoor.json diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 93f505872f4..9a633a38f1a 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -112,6 +112,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "centralite", "da_ref_normal_000001", "da_ref_normal_01011", + "da_ref_normal_01011_onedoor", "da_ref_normal_01001", "vd_network_audio_002s", "vd_network_audio_003s", diff --git a/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011_onedoor.json b/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011_onedoor.json new file mode 100644 index 00000000000..5cb33eb9535 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011_onedoor.json @@ -0,0 +1,1380 @@ +{ + "components": { + "main": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-08-14T05:31:51.945Z" + } + }, + "samsungce.fridgeWelcomeLighting": { + "detectionProximity": { + "value": null + }, + "supportedDetectionProximities": { + "value": null + }, + "status": { + "value": null + } + }, + "samsungce.viewInside": { + "supportedFocusAreas": { + "value": null + }, + "contents": { + "value": null + }, + "lastUpdatedTime": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "00130445", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": "00090026001610304100000021010000", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "description": { + "value": "TP1X_REF_21K", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "TP1X_REF_21K", + "timestamp": "2025-08-14T03:17:25.761Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": "1.0", + "timestamp": "2025-08-12T13:08:24.409Z" + } + }, + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "A-RFWW-TP1-24-T4-COM_20250706", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "di": { + "value": "271d82e9-5b0c-e4b8-058e-cdf23a188610", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "dmv": { + "value": "1.2.1", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "n": { + "value": "Samsung-Refrigerator", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "mnmo": { + "value": "TP1X_REF_21K|00130445|00090026001610304100000021010000", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "vid": { + "value": "DA-REF-NORMAL-01011", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "mnpv": { + "value": "SYSTEM 2.0", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "mnos": { + "value": "TizenRT 4.0", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "pi": { + "value": "271d82e9-5b0c-e4b8-058e-cdf23a188610", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-08-12T13:21:14.953Z" + } + }, + "samsungce.fridgeVacationMode": { + "vacationMode": { + "value": "off", + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "samsungce.driverState": { + "driverState": { + "value": { + "device/0": [ + { + "rt": ["x.com.samsung.devcol", "oic.wk.col"], + "if": ["oic.if.baseline", "oic.if.ll", "oic.if.b"] + }, + { + "href": "/alarms/vs/0", + "rep": { + "href": "/alarms/vs/0" + } + }, + { + "href": "/bespoke/vs/0", + "rep": { + "x.com.samsung.da.BespokeProduct": "On", + "href": "/bespoke/vs/0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "", + "x.com.samsung.da.countryCode": "", + "href": "/configuration/vs/0" + } + }, + { + "href": "/door/onedoorfreezer/vs/0", + "rep": { + "x.com.samsung.da.openState": "Close", + "href": "/door/onedoorfreezer/vs/0" + } + }, + { + "href": "/doors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.openState": "Close", + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Door" + } + ], + "href": "/doors/vs/0" + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "2", + "x.com.samsung.da.override": "Not_Supported", + "x.com.samsung.da.durationminutes": "1129", + "x.com.samsung.da.start": "2025-08-13T12:34:56Z", + "x.com.samsung.da.realSaving": "On", + "href": "/drlc/vs/0" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "263", + "x.com.samsung.da.instantaneousPower": "1", + "x.com.samsung.da.cumulativePower": "801", + "x.com.samsung.da.cumulativeSavedPower": "30", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "href": "/energy/consumption/vs/0" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+02:00", + "x.com.samsung.supprtedtype": 1, + "href": "/file/information/vs/0" + } + }, + { + "href": "/refrigeration/vs/0", + "rep": { + "x.com.samsung.da.rapidFridge": "Off", + "href": "/refrigeration/vs/0" + } + }, + { + "href": "/energy/ailevel/vs/0", + "rep": { + "aiLevel": "1", + "supportedAiLevel": ["1"], + "href": "/energy/ailevel/vs/0" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_REF_21K|00130445|00090026001610304100000021010000", + "x.com.samsung.da.description": "TP1X_REF_21K", + "x.com.samsung.da.serialNum": "08174EAY700355P", + "x.com.samsung.da.otnDUID": "EXCCN6NY7KZ4W", + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagLogType": ["errCode", "dump"], + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "RO0", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "WiFi Module", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "250706", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Micom", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "2408090D, FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ], + "href": "/information/vs/0" + } + }, + { + "href": "/status/lock/vs/0", + "rep": { + "x.com.samsung.da.childlock": "Locked", + "href": "/status/lock/vs/0" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.modes": ["RVACATION_OFF"], + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_REF_OUTDOOR_CONTROL_V2" + ], + "href": "/mode/vs/0" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true", + "href": "/realtimenotiforclient/vs/0" + } + }, + { + "href": "/runningmode/vs/0", + "rep": { + "x.com.samsung.da.runningMode": 0, + "href": "/runningmode/vs/0" + } + }, + { + "href": "/selfcheck/vs/0", + "rep": { + "x.com.samsung.da.supportedActions": ["Start"], + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.result": "Success", + "x.com.samsung.da.error": ["ErrorCode_None"], + "href": "/selfcheck/vs/0" + } + }, + { + "href": "/temperature/desired/cooler/0", + "rep": { + "temperature": 3.0, + "range": [1.0, 7.0], + "units": "C", + "href": "/temperature/desired/cooler/0" + } + }, + { + "href": "/temperature/current/cooler/0", + "rep": { + "temperature": 3.0, + "range": [1.0, 7.0], + "units": "C", + "href": "/temperature/current/cooler/0" + } + }, + { + "href": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Fridge", + "x.com.samsung.da.desired": "3", + "x.com.samsung.da.current": "3", + "x.com.samsung.da.maximum": "7", + "x.com.samsung.da.minimum": "1", + "x.com.samsung.da.unit": "Celsius" + } + ], + "href": "/temperatures/vs/0" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "otnStatus": "None", + "flashingProgress": "0", + "otnCompleteDate": "noHistory", + "scheduledTime": "None", + "swVersionInfo": { + "platform": "Tizen Lite", + "oneUiVersion": "7.0 Refrigerator", + "osVersion": "4.0" + }, + "otnList": [ + { + "type": "WIFI", + "modelId": "A-RFWW-TP1-24-T4-COM", + "versions": ["20250706"], + "visVersion": "250706" + }, + { + "type": "Micom", + "modelId": "028100130445FFFFFFFF", + "versions": ["2408090D", "FFFFFFFF"], + "visVersion": "240809" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Europe/Warsaw", + "offset": "+02:00", + "DST": "ON" + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": ["helper_hotspot", "ble_ocf"], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": ["2.4G"], + "calmConnectionCare": { + "version": "1.0", + "role": ["things"] + } + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "34:FC:99:0A:67:55", + "macaddressBLE": "34:FC:99:0A:67:56" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + }, + { + "href": "/dginformation/vs/0", + "rep": { + "enrolmentstatus": "Unknown", + "devicestate": "Unknown", + "lockstatus": "Unknown", + "nextduedate": "", + "workingminutes": 0, + "paymentinfo": { + "emiplan": "Unknown", + "currency": "Unknown", + "totalemi": 0, + "totalemipaid": 0 + } + } + } + ] + }, + "timestamp": "2025-08-14T03:17:25.764Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "temperatureMeasurement", + "thermostatCoolingSetpoint", + "custom.fridgeMode", + "custom.deodorFilter", + "custom.waterFilter", + "custom.dustFilter", + "samsungce.viewInside", + "samsungce.fridgeWelcomeLighting", + "sec.smartthingsHub", + "samsungce.powerFreeze", + "samsungce.sabbathMode" + ], + "timestamp": "2025-08-13T17:29:03.375Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 25060101, + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "RO0", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "tsId": { + "value": null + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": null + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + }, + "custom.disabledComponents": { + "disabledComponents": { + "value": [ + "icemaker", + "icemaker-02", + "icemaker-03", + "pantry-01", + "pantry-02", + "specialzone-01", + "scale-10", + "scale-11", + "cooler", + "freezer", + "cvroom" + ], + "timestamp": "2025-08-12T12:36:05.791Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": 2, + "start": "2025-08-14T07:24:20Z", + "duration": 1441, + "override": false + }, + "timestamp": "2025-08-14T07:24:23.569Z" + } + }, + "samsungce.sabbathMode": { + "supportedActions": { + "value": null + }, + "status": { + "value": null + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 861, + "deltaEnergy": 0, + "power": 1, + "powerEnergy": 0.27936416665712993, + "persistedEnergy": 0, + "energySaved": 0, + "persistedSavedEnergy": 35, + "start": "2025-08-14T07:04:50Z", + "end": "2025-08-14T07:21:35Z" + }, + "timestamp": "2025-08-14T07:21:35.717Z" + } + }, + "refresh": {}, + "execute": { + "data": { + "value": null + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": true, + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "supportedWiFiFreq": { + "value": ["2.4G"], + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "supportedAuthType": { + "value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"], + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "protocolType": { + "value": ["helper_hotspot", "ble_ocf"], + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "samsungce.selfCheck": { + "result": { + "value": "passed", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "supportedActions": { + "value": ["start"], + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "progress": { + "value": null + }, + "errors": { + "value": [], + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "status": { + "value": "ready", + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "0", + "swType": "Software", + "versionNumber": "250706", + "description": "WiFi Module" + }, + { + "id": "1", + "swType": "Firmware", + "versionNumber": "2408090D, FFFFFFFF", + "description": "Micom" + } + ], + "timestamp": "2025-08-12T13:21:17.127Z" + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": null + }, + "dustFilterUsage": { + "value": null + }, + "dustFilterLastResetDate": { + "value": null + }, + "dustFilterStatus": { + "value": null + }, + "dustFilterCapacity": { + "value": null + }, + "dustFilterResetType": { + "value": null + } + }, + "refrigeration": { + "defrost": { + "value": null + }, + "rapidCooling": { + "value": "off", + "timestamp": "2025-08-12T14:27:24.223Z" + }, + "rapidFreezing": { + "value": null + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null + }, + "deodorFilterLastResetDate": { + "value": null + }, + "deodorFilterStatus": { + "value": null + }, + "deodorFilterResetType": { + "value": null + }, + "deodorFilterUsage": { + "value": null + }, + "deodorFilterUsageStep": { + "value": null + } + }, + "samsungce.powerCool": { + "activated": { + "value": false, + "timestamp": "2025-08-12T14:27:24.223Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "energySavingSupport": { + "value": true, + "timestamp": "2025-08-13T12:50:26.756Z" + }, + "drMaxDuration": { + "value": 99999999, + "unit": "min", + "timestamp": "2025-08-12T12:36:06.104Z" + }, + "energySavingLevel": { + "value": 1, + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": [1], + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "energySavingOperation": { + "value": true, + "timestamp": "2025-08-14T07:24:28.808Z" + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": true, + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-08-12T13:14:52.642Z" + }, + "otnDUID": { + "value": "EXCCN6NY7KZ4W", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "operatingState": { + "value": "none", + "timestamp": "2025-08-12T13:14:52.642Z" + }, + "progress": { + "value": 0, + "unit": "%", + "timestamp": "2025-08-12T13:08:24.243Z" + } + }, + "samsungce.powerFreeze": { + "activated": { + "value": null + } + }, + "sec.smartthingsHub": { + "threadHardwareAvailability": { + "value": null + }, + "availability": { + "value": null + }, + "deviceId": { + "value": null + }, + "zigbeeHardwareAvailability": { + "value": null + }, + "version": { + "value": null + }, + "threadRequiresExternalHardware": { + "value": null + }, + "zigbeeRequiresExternalHardware": { + "value": null + }, + "eui": { + "value": null + }, + "lastOnboardingResult": { + "value": null + }, + "zwaveHardwareAvailability": { + "value": null + }, + "zwaveRequiresExternalHardware": { + "value": null + }, + "state": { + "value": null + }, + "onboardingProgress": { + "value": null + }, + "lastOnboardingErrorCode": { + "value": null + } + }, + "custom.waterFilter": { + "waterFilterUsageStep": { + "value": null + }, + "waterFilterResetType": { + "value": null + }, + "waterFilterCapacity": { + "value": null + }, + "waterFilterLastResetDate": { + "value": null + }, + "waterFilterUsage": { + "value": null + }, + "waterFilterStatus": { + "value": null + } + } + }, + "cvroom": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + }, + "specialzone-01": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + } + }, + "pantry-01": { + "samsungce.foodDefrost": { + "supportedOptions": { + "value": null + }, + "foodType": { + "value": null + }, + "weight": { + "value": null + }, + "operationTime": { + "value": null + }, + "remainingTime": { + "value": null + } + }, + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "samsungce.fridgePantryInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.meatAging": { + "zoneInfo": { + "value": null + }, + "supportedMeatTypes": { + "value": null + }, + "supportedAgingMethods": { + "value": null + }, + "status": { + "value": null + } + }, + "samsungce.fridgePantryMode": { + "mode": { + "value": null + }, + "supportedModes": { + "value": null + } + } + }, + "pantry-02": { + "samsungce.foodDefrost": { + "supportedOptions": { + "value": null + }, + "foodType": { + "value": null + }, + "weight": { + "value": null + }, + "operationTime": { + "value": null + }, + "remainingTime": { + "value": null + } + }, + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "samsungce.fridgePantryInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.meatAging": { + "zoneInfo": { + "value": null + }, + "supportedMeatTypes": { + "value": null + }, + "supportedAgingMethods": { + "value": null + }, + "status": { + "value": null + } + }, + "samsungce.fridgePantryMode": { + "mode": { + "value": null + }, + "supportedModes": { + "value": null + } + } + }, + "icemaker": { + "samsungce.fridgeIcemakerInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + }, + "onedoor": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-08-14T05:31:51.945Z" + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.freezerConvertMode", "custom.fridgeMode"], + "timestamp": "2025-08-12T12:36:06.177Z" + } + }, + "samsungce.temperatureSetting": { + "supportedDesiredTemperatures": { + "value": null + }, + "desiredTemperature": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 3, + "unit": "C", + "timestamp": "2025-08-13T19:45:46.907Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 1, + "unit": "C", + "timestamp": "2025-08-12T12:36:06.031Z" + }, + "maximumSetpoint": { + "value": 7, + "unit": "C", + "timestamp": "2025-08-12T12:36:06.031Z" + } + }, + "samsungce.freezerConvertMode": { + "supportedFreezerConvertModes": { + "value": null + }, + "freezerConvertMode": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": 1, + "maximum": 7, + "step": 1 + }, + "unit": "C", + "timestamp": "2025-08-12T12:36:06.031Z" + }, + "coolingSetpoint": { + "value": 3, + "unit": "C", + "timestamp": "2025-08-13T19:43:49.744Z" + } + } + }, + "scale-10": { + "samsungce.connectionState": { + "connectionState": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.weightMeasurement": { + "weight": { + "value": null + } + }, + "samsungce.weightMeasurementCalibration": {} + }, + "scale-11": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.weightMeasurement": { + "weight": { + "value": null + } + } + }, + "cooler": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": null + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.temperatureSetting", "custom.fridgeMode"], + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "samsungce.temperatureSetting": { + "supportedDesiredTemperatures": { + "value": null + }, + "desiredTemperature": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 21, + "unit": "C", + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 1, + "unit": "C", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "maximumSetpoint": { + "value": 7, + "unit": "C", + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": 1, + "maximum": 7, + "step": 1 + }, + "unit": "C", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "coolingSetpoint": { + "value": 3, + "unit": "C", + "timestamp": "2025-08-12T12:36:03.771Z" + } + } + }, + "freezer": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": null + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["custom.fridgeMode", "samsungce.freezerConvertMode"], + "timestamp": "2025-08-12T12:42:30.879Z" + } + }, + "samsungce.temperatureSetting": { + "supportedDesiredTemperatures": { + "value": null + }, + "desiredTemperature": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": null + }, + "maximumSetpoint": { + "value": null + } + }, + "samsungce.freezerConvertMode": { + "supportedFreezerConvertModes": { + "value": null + }, + "freezerConvertMode": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + }, + "icemaker-02": { + "samsungce.fridgeIcemakerInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + }, + "icemaker-03": { + "samsungce.fridgeIcemakerInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_ref_normal_01011_onedoor.json b/tests/components/smartthings/fixtures/devices/da_ref_normal_01011_onedoor.json new file mode 100644 index 00000000000..2669768b719 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ref_normal_01011_onedoor.json @@ -0,0 +1,588 @@ +{ + "items": [ + { + "deviceId": "271d82e0-5b0c-e4b8-058e-cdf23a188610", + "name": "Samsung-Refrigerator", + "label": "Lod\u00f3wka", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-REF-NORMAL-01011", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "5274d210-9bd8-4a14-ae55-52a9ffeedfb7", + "ownerId": "d40034d0-c87b-3fa6-da98-108c42c36a6b", + "roomId": "b19fa610-62f8-4109-b9cc-47f85fcefd29", + "deviceTypeName": "Samsung OCF Refrigerator", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "refrigeration", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "custom.waterFilter", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.driverState", + "version": 1 + }, + { + "id": "samsungce.fridgeVacationMode", + "version": 1 + }, + { + "id": "samsungce.powerCool", + "version": 1 + }, + { + "id": "samsungce.powerFreeze", + "version": 1 + }, + { + "id": "samsungce.sabbathMode", + "version": 1 + }, + { + "id": "samsungce.selfCheck", + "version": 1 + }, + { + "id": "samsungce.viewInside", + "version": 1 + }, + { + "id": "samsungce.fridgeWelcomeLighting", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + }, + { + "id": "sec.smartthingsHub", + "version": 1, + "ephemeral": true + } + ], + "categories": [ + { + "name": "Refrigerator", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "freezer", + "label": "freezer", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.temperatureSetting", + "version": 1 + }, + { + "id": "samsungce.freezerConvertMode", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "cooler", + "label": "cooler", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.temperatureSetting", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "cvroom", + "label": "cvroom", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "onedoor", + "label": "onedoor", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.temperatureSetting", + "version": 1 + }, + { + "id": "samsungce.freezerConvertMode", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "icemaker", + "label": "icemaker", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "samsungce.fridgeIcemakerInfo", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "icemaker-02", + "label": "icemaker-02", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "samsungce.fridgeIcemakerInfo", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "icemaker-03", + "label": "icemaker-03", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "samsungce.fridgeIcemakerInfo", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "scale-10", + "label": "scale-10", + "capabilities": [ + { + "id": "samsungce.weightMeasurement", + "version": 1 + }, + { + "id": "samsungce.weightMeasurementCalibration", + "version": 1 + }, + { + "id": "samsungce.connectionState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "scale-11", + "label": "scale-11", + "capabilities": [ + { + "id": "samsungce.weightMeasurement", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "pantry-01", + "label": "pantry-01", + "capabilities": [ + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "samsungce.fridgePantryInfo", + "version": 1 + }, + { + "id": "samsungce.fridgePantryMode", + "version": 1 + }, + { + "id": "samsungce.meatAging", + "version": 1 + }, + { + "id": "samsungce.foodDefrost", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "pantry-02", + "label": "pantry-02", + "capabilities": [ + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "samsungce.fridgePantryInfo", + "version": 1 + }, + { + "id": "samsungce.fridgePantryMode", + "version": 1 + }, + { + "id": "samsungce.meatAging", + "version": 1 + }, + { + "id": "samsungce.foodDefrost", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "specialzone-01", + "label": "specialzone-01", + "capabilities": [ + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2025-08-12T12:35:56.924Z", + "profile": { + "id": "840ff773-857b-324b-a54e-ba31a8155c4d" + }, + "ocf": { + "ocfDeviceType": "oic.d.refrigerator", + "name": "Samsung-Refrigerator", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP1X_REF_21K|00130445|00090026001610304100000021010000", + "platformVersion": "SYSTEM 2.0", + "platformOS": "TizenRT 4.0", + "hwVersion": "Realtek", + "firmwareVersion": "A-RFWW-TP1-24-T4-COM_20250706", + "vendorId": "DA-REF-NORMAL-01011", + "vendorResourceClientServerVersion": "MediaTek Release 250706", + "lastSignupTime": "2025-08-12T12:35:56.864318132Z", + "transferCandidate": true, + "additionalAuthCodeRequired": false, + "modelCode": "RR39C7EC5B1/EF" + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 7be4d3af55b..4637de49efb 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -1169,6 +1169,55 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_01011_onedoor][binary_sensor.lodowka_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.lodowka_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '271d82e0-5b0c-e4b8-058e-cdf23a188610_main_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][binary_sensor.lodowka_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Lodówka Door', + }), + 'context': , + 'entity_id': 'binary_sensor.lodowka_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 17a9d6691cc..5fd7040f61b 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -684,6 +684,37 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ref_normal_01011_onedoor] + 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': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '271d82e0-5b0c-e4b8-058e-cdf23a188610', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP1X_REF_21K', + 'model_id': None, + 'name': 'Lodówka', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': 'A-RFWW-TP1-24-T4-COM_20250706', + 'via_device_id': None, + }) +# --- # name: test_devices[da_rvc_map_01011] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 169359118da..8771ed505cf 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -6066,6 +6066,288 @@ 'state': '97', }) # --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_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.lodowka_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': '271d82e0-5b0c-e4b8-058e-cdf23a188610_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Lodówka Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lodowka_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.861', + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_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.lodowka_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': '271d82e0-5b0c-e4b8-058e-cdf23a188610_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Lodówka Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lodowka_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_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.lodowka_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': '271d82e0-5b0c-e4b8-058e-cdf23a188610_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Lodówka Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lodowka_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_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.lodowka_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': '271d82e0-5b0c-e4b8-058e-cdf23a188610_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Lodówka Power', + 'power_consumption_end': '2025-08-14T07:21:35Z', + 'power_consumption_start': '2025-08-14T07:04:50Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lodowka_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_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.lodowka_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': '271d82e0-5b0c-e4b8-058e-cdf23a188610_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Lodówka Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lodowka_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.00027936416665713', + }) +# --- # name: test_all_entities[da_rvc_map_01011][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 1aaeb35205f..6512e88998b 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_ref_normal_01011_onedoor][switch.lodowka_power_cool-entry] + EntityRegistryEntrySnapshot({ + 'aliases': 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.lodowka_power_cool', + 'has_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 cool', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_cool', + 'unique_id': '271d82e0-5b0c-e4b8-058e-cdf23a188610_main_samsungce.powerCool_activated_activated', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][switch.lodowka_power_cool-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lodówka Power cool', + }), + 'context': , + 'entity_id': 'switch.lodowka_power_cool', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_rvc_map_01011][switch.robot_vacuum-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 382e7dfd39f4c8f5656892499f798eb5823b8570 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 14 Aug 2025 14:51:43 +0200 Subject: [PATCH 1015/1113] Add Tuya test fixtures (#150622) --- tests/components/tuya/__init__.py | 4 + .../tuya/fixtures/cz_fencxse0bnut96ig.json | 100 ++++ .../tuya/fixtures/cz_ipabufmlmodje1ws.json | 165 ++++++ .../tuya/fixtures/cz_z6pht25s3p0gs26q.json | 207 +++++++ .../tuya/fixtures/wk_ccpwojhalfxryigz.json | 121 ++++ .../tuya/snapshots/test_climate.ambr | 65 +++ .../components/tuya/snapshots/test_init.ambr | 124 +++++ .../tuya/snapshots/test_number.ambr | 58 ++ .../tuya/snapshots/test_select.ambr | 59 ++ .../tuya/snapshots/test_sensor.ambr | 522 ++++++++++++++++++ .../tuya/snapshots/test_switch.ambr | 244 ++++++++ 11 files changed, 1669 insertions(+) create mode 100644 tests/components/tuya/fixtures/cz_fencxse0bnut96ig.json create mode 100644 tests/components/tuya/fixtures/cz_ipabufmlmodje1ws.json create mode 100644 tests/components/tuya/fixtures/cz_z6pht25s3p0gs26q.json create mode 100644 tests/components/tuya/fixtures/wk_ccpwojhalfxryigz.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index c48c99da9fa..537fc98854a 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -41,11 +41,13 @@ DEVICE_MOCKS = [ "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_fencxse0bnut96ig", # https://github.com/home-assistant/core/issues/63978 "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_ipabufmlmodje1ws", # https://github.com/home-assistant/core/issues/63978 "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 @@ -61,6 +63,7 @@ DEVICE_MOCKS = [ "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 + "cz_z6pht25s3p0gs26q", # https://github.com/home-assistant/core/issues/63978 "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 @@ -167,6 +170,7 @@ DEVICE_MOCKS = [ "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_ccpwojhalfxryigz", # https://github.com/home-assistant/core/issues/145551 "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 diff --git a/tests/components/tuya/fixtures/cz_fencxse0bnut96ig.json b/tests/components/tuya/fixtures/cz_fencxse0bnut96ig.json new file mode 100644 index 00000000000..a244a6c8bcb --- /dev/null +++ b/tests/components/tuya/fixtures/cz_fencxse0bnut96ig.json @@ -0,0 +1,100 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "46", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Spa", + "model": "JLCZ8266", + "category": "cz", + "product_id": "fencxse0bnut96ig", + "product_name": "smart plug", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2022-01-28T07:32:38+00:00", + "create_time": "2022-01-28T07:32:38+00:00", + "update_time": "2022-01-28T07:32: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": 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": 203, + "cur_current": 5404, + "cur_power": 12018, + "cur_voltage": 2381 + } +} diff --git a/tests/components/tuya/fixtures/cz_ipabufmlmodje1ws.json b/tests/components/tuya/fixtures/cz_ipabufmlmodje1ws.json new file mode 100644 index 00000000000..5edf6500132 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_ipabufmlmodje1ws.json @@ -0,0 +1,165 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "46", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "V\u00e4rmelampa", + "model": "FK-PW802EC-F", + "category": "cz", + "product_id": "ipabufmlmodje1ws", + "product_name": "", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2022-01-22T12:55:50+00:00", + "create_time": "2022-01-21T20:32:42+00:00", + "update_time": "2022-01-22T12:55:53+00:00", + "function": { + "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 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "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": 0, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "test_bit": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "voltage_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electric_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "power_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electricity_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 82, + "cur_current": 435, + "cur_power": 1642, + "cur_voltage": 2246, + "test_bit": 1, + "voltage_coe": 632, + "electric_coe": 10795, + "power_coe": 10197, + "electricity_coe": 4090 + } +} diff --git a/tests/components/tuya/fixtures/cz_z6pht25s3p0gs26q.json b/tests/components/tuya/fixtures/cz_z6pht25s3p0gs26q.json new file mode 100644 index 00000000000..01caa14439a --- /dev/null +++ b/tests/components/tuya/fixtures/cz_z6pht25s3p0gs26q.json @@ -0,0 +1,207 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "61", + "app_type": "tuyaSmart", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "6294HA", + "model": "", + "category": "cz", + "product_id": "z6pht25s3p0gs26q", + "product_name": "6294HA", + "online": true, + "sub": false, + "time_zone": "+10:00", + "active_time": "2022-01-20T11:03:22+00:00", + "create_time": "2022-01-10T01:30:10+00:00", + "update_time": "2022-01-20T11:03:22+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"] + } + }, + "child_lock": { + "type": "Boolean", + "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": { + "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": 0, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "test_bit": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "voltage_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electric_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "power_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electricity_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_1": true, + "switch_2": false, + "countdown_1": 0, + "countdown_2": 0, + "add_ele": 201, + "cur_current": 5466, + "cur_power": 11374, + "cur_voltage": 2396, + "test_bit": 2, + "voltage_coe": 0, + "electric_coe": 0, + "power_coe": 0, + "electricity_coe": 0, + "relay_status": "power_on", + "child_lock": false + } +} diff --git a/tests/components/tuya/fixtures/wk_ccpwojhalfxryigz.json b/tests/components/tuya/fixtures/wk_ccpwojhalfxryigz.json new file mode 100644 index 00000000000..ed489927c1e --- /dev/null +++ b/tests/components/tuya/fixtures/wk_ccpwojhalfxryigz.json @@ -0,0 +1,121 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Boiler Temperature Controller", + "category": "wk", + "product_id": "ccpwojhalfxryigz", + "product_name": "Intelligent temperature controller", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-01-24T19:20:54+00:00", + "create_time": "2024-01-24T19:20:54+00:00", + "update_time": "2024-01-24T19:20:54+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 1200, + "scale": 1, + "step": 1 + } + }, + "lower_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 1200, + "scale": 1, + "step": 1 + } + }, + "temp_correction": { + "type": "Integer", + "value": { + "unit": "", + "min": -99, + "max": 99, + "scale": 1, + "step": 1 + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "work_state": { + "type": "Enum", + "value": { + "range": ["cold", "hot"] + } + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 1200, + "scale": 1, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 1200, + "scale": 1, + "step": 1 + } + }, + "lower_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 1200, + "scale": 1, + "step": 1 + } + }, + "temp_correction": { + "type": "Integer", + "value": { + "unit": "", + "min": -99, + "max": 99, + "scale": 1, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["e1", "e2", "e3"] + } + } + }, + "status": { + "switch": true, + "work_state": "hot", + "upper_temp": 585, + "temp_current": 575, + "lower_temp": 600, + "temp_correction": -8, + "fault": 0 + }, + "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 270eeef1577..445fb3f8cc6 100644 --- a/tests/components/tuya/snapshots/test_climate.ambr +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -74,6 +74,71 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[climate.boiler_temperature_controller-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + '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.boiler_temperature_controller', + 'has_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.zgiyrxflahjowpcckw', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.boiler_temperature_controller-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 57.5, + 'friendly_name': 'Boiler Temperature Controller', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_step': 1.0, + }), + 'context': , + 'entity_id': 'climate.boiler_temperature_controller', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- # name: test_platform_setup_and_discovery[climate.clima_cucina-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 52ff63dac36..4491ce180ac 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -2324,6 +2324,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[gi69tunb0esxcnefzc] + 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', + 'gi69tunb0esxcnefzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'smart plug', + 'model_id': 'fencxse0bnut96ig', + 'name': 'Spa', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[gjnpc0eojd] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -4122,6 +4153,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[q62sg0p3s52thp6zzc] + 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', + 'q62sg0p3s52thp6zzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '6294HA', + 'model_id': 'z6pht25s3p0gs26q', + 'name': '6294HA', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[q8dncqpgin4yympisc] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -4649,6 +4711,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[sw1ejdomlmfubapizc] + 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', + 'sw1ejdomlmfubapizc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '', + 'model_id': 'ipabufmlmodje1ws', + 'name': 'Värmelampa', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[swhtzki3qrz5ydchjboc] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -5610,6 +5703,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[zgiyrxflahjowpcckw] + 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', + 'zgiyrxflahjowpcckw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Intelligent temperature controller', + 'model_id': 'ccpwojhalfxryigz', + 'name': 'Boiler Temperature Controller', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[zjh9xhtm3gibs9kizc] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index b1007431046..1aa8c3dcca9 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -58,6 +58,64 @@ 'state': '1.0', }) # --- +# name: test_platform_setup_and_discovery[number.boiler_temperature_controller_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.boiler_temperature_controller_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.zgiyrxflahjowpcckwtemp_correction', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[number.boiler_temperature_controller_temperature_correction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Boiler Temperature Controller Temperature correction', + 'max': 9.9, + 'min': -9.9, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'number.boiler_temperature_controller_temperature_correction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-0.8', + }) +# --- # name: test_platform_setup_and_discovery[number.c9_volume-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 4e701b39566..08928a6440c 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -176,6 +176,65 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[select.6294ha_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.6294ha_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.q62sg0p3s52thp6zzcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.6294ha_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '6294HA Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.6294ha_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'power_on', + }) +# --- # name: test_platform_setup_and_discovery[select.aqi_volume-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 0d113454d4d..b8e84328e1f 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -173,6 +173,180 @@ 'state': '231.9', }) # --- +# name: test_platform_setup_and_discovery[sensor.6294ha_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.6294ha_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.q62sg0p3s52thp6zzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.6294ha_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': '6294HA Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.6294ha_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.466', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.6294ha_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.6294ha_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.q62sg0p3s52thp6zzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.6294ha_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': '6294HA Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.6294ha_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11374.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.6294ha_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.6294ha_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.q62sg0p3s52thp6zzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.6294ha_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': '6294HA Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.6294ha_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '239.6', + }) +# --- # name: test_platform_setup_and_discovery[sensor.aqi_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -11549,6 +11723,180 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[sensor.spa_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.spa_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.gi69tunb0esxcnefzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.spa_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Spa Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.spa_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.404', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.spa_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.spa_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.gi69tunb0esxcnefzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.spa_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Spa Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.spa_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1201.8', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.spa_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.spa_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.gi69tunb0esxcnefzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.spa_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Spa Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.spa_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '238.1', + }) +# --- # name: test_platform_setup_and_discovery[sensor.steel_cage_door_battery_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -12275,6 +12623,180 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[sensor.varmelampa_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.varmelampa_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.sw1ejdomlmfubapizccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.varmelampa_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Värmelampa Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.varmelampa_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.435', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.varmelampa_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.varmelampa_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.sw1ejdomlmfubapizccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.varmelampa_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Värmelampa Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.varmelampa_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1642.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.varmelampa_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.varmelampa_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.sw1ejdomlmfubapizccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.varmelampa_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Värmelampa Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.varmelampa_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '224.6', + }) +# --- # name: test_platform_setup_and_discovery[sensor.water_fountain_filter_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 96d88a967ff..2e5d1066fef 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -292,6 +292,152 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[switch.6294ha_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.6294ha_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.q62sg0p3s52thp6zzcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.6294ha_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '6294HA Child lock', + }), + 'context': , + 'entity_id': 'switch.6294ha_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.6294ha_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.6294ha_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.q62sg0p3s52thp6zzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.6294ha_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': '6294HA Socket 1', + }), + 'context': , + 'entity_id': 'switch.6294ha_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.6294ha_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.6294ha_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.q62sg0p3s52thp6zzcswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.6294ha_socket_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': '6294HA Socket 2', + }), + 'context': , + 'entity_id': 'switch.6294ha_socket_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.ac_charging_control_box_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6872,6 +7018,55 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[switch.spa_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.spa_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.gi69tunb0esxcnefzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.spa_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Spa Socket 1', + }), + 'context': , + 'entity_id': 'switch.spa_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[switch.spot_1_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7705,6 +7900,55 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[switch.varmelampa_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.varmelampa_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.sw1ejdomlmfubapizcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.varmelampa_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Värmelampa Socket 1', + }), + 'context': , + 'entity_id': 'switch.varmelampa_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[switch.wallwasher_front_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 3eecfa8e57c29241827e6fda21af208ca180b0a9 Mon Sep 17 00:00:00 2001 From: DeerMaximum <43999966+DeerMaximum@users.noreply.github.com> Date: Thu, 14 Aug 2025 16:36:04 +0200 Subject: [PATCH 1016/1113] Set PARALLEL_UPDATES in NINA (#150635) --- homeassistant/components/nina/binary_sensor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index be37a802d47..cfbdd87a0e2 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -52,6 +52,9 @@ async def async_setup_entry( ) +PARALLEL_UPDATES = 0 + + class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEntity): """Representation of an NINA warning.""" From d9b6f826391d95dfa772dfb71bc5405f2992fd6d Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 14 Aug 2025 16:37:44 +0200 Subject: [PATCH 1017/1113] Add Z-Wave Fortrezz SSA2 discovery (#150629) --- .../components/zwave_js/discovery.py | 2 +- tests/components/zwave_js/conftest.py | 14 + .../fixtures/fortrezz_ssa2_siren_state.json | 447 ++++++++++++++++++ tests/components/zwave_js/test_discovery.py | 10 + 4 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 tests/components/zwave_js/fixtures/fortrezz_ssa2_siren_state.json diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 25c342cf87d..7030009f5ad 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -760,7 +760,7 @@ DISCOVERY_SCHEMAS = [ platform=Platform.SELECT, hint="multilevel_switch", manufacturer_id={0x0084}, - product_id={0x0107, 0x0108, 0x010B, 0x0205}, + product_id={0x0107, 0x0108, 0x0109, 0x010B, 0x0205}, product_type={0x0311, 0x0313, 0x0331, 0x0341, 0x0343}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, data_template=BaseDiscoverySchemaDataTemplate( diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index eef92a7eb0a..f60c0169055 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -427,6 +427,12 @@ def fortrezz_ssa1_siren_state_fixture() -> dict[str, Any]: return load_json_object_fixture("fortrezz_ssa1_siren_state.json", DOMAIN) +@pytest.fixture(name="fortrezz_ssa2_siren_state", scope="package") +def fortrezz_ssa2_siren_state_fixture() -> dict[str, Any]: + """Load the fortrezz ssa2 siren node state fixture data.""" + return load_json_object_fixture("fortrezz_ssa2_siren_state.json", DOMAIN) + + @pytest.fixture(name="fortrezz_ssa3_siren_state", scope="package") def fortrezz_ssa3_siren_state_fixture() -> dict[str, Any]: """Load the fortrezz ssa3 siren node state fixture data.""" @@ -1218,6 +1224,14 @@ def fortrezz_ssa1_siren_fixture(client, fortrezz_ssa1_siren_state) -> Node: return node +@pytest.fixture(name="fortrezz_ssa2_siren") +def fortrezz_ssa2_siren_fixture(client, fortrezz_ssa2_siren_state) -> Node: + """Mock a fortrezz ssa2 siren node.""" + node = Node(client, copy.deepcopy(fortrezz_ssa2_siren_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="fortrezz_ssa3_siren") def fortrezz_ssa3_siren_fixture(client, fortrezz_ssa3_siren_state) -> Node: """Mock a fortrezz ssa3 siren node.""" diff --git a/tests/components/zwave_js/fixtures/fortrezz_ssa2_siren_state.json b/tests/components/zwave_js/fixtures/fortrezz_ssa2_siren_state.json new file mode 100644 index 00000000000..6fc7a89046e --- /dev/null +++ b/tests/components/zwave_js/fixtures/fortrezz_ssa2_siren_state.json @@ -0,0 +1,447 @@ +{ + "nodeId": 30, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 132, + "productId": 265, + "productType": 785, + "firmwareVersion": "1.9", + "deviceConfig": { + "filename": "/data/db/devices/0x0084/ssa1_ssa2.json", + "isEmbedded": true, + "manufacturer": "FortrezZ LLC", + "manufacturerId": 132, + "label": "SSA1/SSA2", + "description": "Siren and Strobe Alarm", + "devices": [ + { + "productType": 785, + "productId": 267 + }, + { + "productType": 787, + "productId": 264 + }, + { + "productType": 787, + "productId": 267 + }, + { + "productType": 785, + "productId": 265 + }, + { + "productType": 787, + "productId": 265 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "paramInformation": { + "_map": {} + } + }, + "label": "SSA1/SSA2", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 40000, + "supportedDataRates": [40000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0084:0x0311:0x0109:1.9", + "statistics": { + "commandsTX": 23, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 22, + "rtt": 50.7, + "lastSeen": "2025-06-10T03:23:40.329Z" + }, + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2025-06-04T08:00:19.437Z", + "protocol": 0, + "values": [ + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "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": "Up", + "propertyName": "Up", + "ccVersion": 1, + "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": 1, + "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": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Delay Before Accept of Basic Set Off", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Delay, from the time the siren-strobe turns on", + "label": "Delay Before Accept of Basic Set Off", + "default": 0, + "min": 0, + "max": 255, + "unit": "seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 265 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 785 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 132 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 1, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.9"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 1, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "2.97" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 6 + } + ], + "endpoints": [ + { + "nodeId": 30, + "index": 0, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 1, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 2, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 6a4752d536b..299c003aefe 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -157,6 +157,16 @@ async def test_lock_popp_electric_strike_lock_control( assert hass.states.get("select.node_62_current_lock_mode") is not None +async def test_fortrez_ssa2_siren( + hass: HomeAssistant, + client: MagicMock, + fortrezz_ssa2_siren: Node, + integration: MockConfigEntry, +) -> None: + """Test Fortrezz SSA2 siren gets discovered correctly.""" + assert hass.states.get("select.siren_and_strobe_alarm") is not None + + async def test_fortrez_ssa3_siren( hass: HomeAssistant, client, fortrezz_ssa3_siren, integration ) -> None: From 2248584a0fadfa2a55c14450d34e2bd6bd6bbc96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Thu, 14 Aug 2025 17:07:18 +0200 Subject: [PATCH 1018/1113] Add Matter Electrical measurements additional attributes (#150188) Co-authored-by: Martin Hjelmare --- homeassistant/components/matter/sensor.py | 104 ++++ homeassistant/components/matter/strings.json | 18 + .../fixtures/nodes/silabs_dishwasher.json | 22 - .../fixtures/nodes/silabs_laundrywasher.json | 22 - .../matter/snapshots/test_sensor.ambr | 554 +++++++++--------- 5 files changed, 399 insertions(+), 321 deletions(-) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 9e2ef33167b..af7dd385fd0 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -32,11 +32,13 @@ from homeassistant.const import ( REVOLUTIONS_PER_MINUTE, EntityCategory, Platform, + UnitOfApparentPower, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, UnitOfPower, UnitOfPressure, + UnitOfReactivePower, UnitOfTemperature, UnitOfTime, UnitOfVolume, @@ -782,10 +784,43 @@ DISCOVERY_SCHEMAS = [ clusters.ElectricalPowerMeasurement.Attributes.ActivePower, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalPowerMeasurementApparentPower", + device_class=SensorDeviceClass.APPARENT_POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfApparentPower.MILLIVOLT_AMPERE, + suggested_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ElectricalPowerMeasurement.Attributes.ApparentPower, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalPowerMeasurementReactivePower", + device_class=SensorDeviceClass.REACTIVE_POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE, + suggested_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ElectricalPowerMeasurement.Attributes.ReactivePower, + ), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( key="ElectricalPowerMeasurementVoltage", + translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, @@ -796,10 +831,45 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterSensor, required_attributes=(clusters.ElectricalPowerMeasurement.Attributes.Voltage,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalPowerMeasurementRMSVoltage", + translation_key="rms_voltage", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=0, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ElectricalPowerMeasurement.Attributes.RMSVoltage, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalPowerMeasurementApparentCurrent", + translation_key="apparent_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ElectricalPowerMeasurement.Attributes.ApparentCurrent, + ), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( key="ElectricalPowerMeasurementActiveCurrent", + translation_key="active_current", device_class=SensorDeviceClass.CURRENT, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, @@ -812,6 +882,40 @@ DISCOVERY_SCHEMAS = [ clusters.ElectricalPowerMeasurement.Attributes.ActiveCurrent, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalPowerMeasurementReactiveCurrent", + translation_key="reactive_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ElectricalPowerMeasurement.Attributes.ReactiveCurrent, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalPowerMeasurementRMSCurrent", + translation_key="rms_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ElectricalPowerMeasurement.Attributes.RMSCurrent, + ), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 6355ebfbee6..8cd2fdf6adf 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -451,6 +451,24 @@ }, "window_covering_target_position": { "name": "Target opening position" + }, + "active_current": { + "name": "Active current" + }, + "apparent_current": { + "name": "Apparent current" + }, + "reactive_current": { + "name": "Reactive current" + }, + "rms_current": { + "name": "Effective current" + }, + "rms_voltage": { + "name": "Effective voltage" + }, + "voltage": { + "name": "Voltage" } }, "switch": { diff --git a/tests/components/matter/fixtures/nodes/silabs_dishwasher.json b/tests/components/matter/fixtures/nodes/silabs_dishwasher.json index d0efcc7e004..fa66f4dfeef 100644 --- a/tests/components/matter/fixtures/nodes/silabs_dishwasher.json +++ b/tests/components/matter/fixtures/nodes/silabs_dishwasher.json @@ -588,31 +588,9 @@ "10": 101 } ], - "2/144/4": 120000, - "2/144/5": 0, - "2/144/6": 0, - "2/144/7": 0, "2/144/8": 0, - "2/144/9": 0, - "2/144/10": 0, "2/144/11": 120000, "2/144/12": 0, - "2/144/13": 0, - "2/144/14": 60, - "2/144/15": [ - { - "0": 1, - "1": 100000 - } - ], - "2/144/16": [ - { - "0": 1, - "1": 100000 - } - ], - "2/144/17": 9800, - "2/144/18": 0, "2/144/65532": 31, "2/144/65533": 1, "2/144/65528": [], diff --git a/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json index 3b1ed0043de..93ba7e2e026 100644 --- a/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json +++ b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json @@ -832,31 +832,9 @@ "10": 129 } ], - "2/144/4": 120000, - "2/144/5": 0, - "2/144/6": 0, - "2/144/7": 0, "2/144/8": 0, - "2/144/9": 0, - "2/144/10": 0, "2/144/11": 120000, "2/144/12": 0, - "2/144/13": 0, - "2/144/14": 60, - "2/144/15": [ - { - "0": 1, - "1": 100000 - } - ], - "2/144/16": [ - { - "0": 1, - "1": 100000 - } - ], - "2/144/17": 9800, - "2/144/18": 0, "2/144/65532": 31, "2/144/65533": 1, "2/144/65528": [], diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index bff4ad7909d..b7aff460d77 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -1251,6 +1251,65 @@ 'state': '189.0', }) # --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_active_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_battery_storage_active_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': 'Active current', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'active_current', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ElectricalPowerMeasurementActiveCurrent-144-5', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_active_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Mock Battery Storage Active current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_battery_storage_active_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[battery_storage][sensor.mock_battery_storage_appliance_energy_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1427,65 +1486,6 @@ 'state': '48.0', }) # --- -# name: test_sensors[battery_storage][sensor.mock_battery_storage_current-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.mock_battery_storage_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': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ElectricalPowerMeasurementActiveCurrent-144-5', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[battery_storage][sensor.mock_battery_storage_current-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'current', - 'friendly_name': 'Mock Battery Storage Current', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.mock_battery_storage_current', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- # name: test_sensors[battery_storage][sensor.mock_battery_storage_energy_optimization_opt_out-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1763,7 +1763,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'voltage', 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ElectricalPowerMeasurementVoltage-144-4', 'unit_of_measurement': , }) @@ -2176,7 +2176,7 @@ 'state': '238.800003051758', }) # --- -# name: test_sensors[eve_energy_plug_patched][sensor.eve_energy_plug_patched_current-entry] +# name: test_sensors[eve_energy_plug_patched][sensor.eve_energy_plug_patched_active_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2191,7 +2191,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.eve_energy_plug_patched_current', + 'entity_id': 'sensor.eve_energy_plug_patched_active_current', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2209,26 +2209,26 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Current', + 'original_name': 'Active current', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_current', 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', 'unit_of_measurement': , }) # --- -# name: test_sensors[eve_energy_plug_patched][sensor.eve_energy_plug_patched_current-state] +# name: test_sensors[eve_energy_plug_patched][sensor.eve_energy_plug_patched_active_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'Eve Energy Plug Patched Current', + 'friendly_name': 'Eve Energy Plug Patched Active current', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.eve_energy_plug_patched_current', + 'entity_id': 'sensor.eve_energy_plug_patched_active_current', 'last_changed': , 'last_reported': , 'last_updated': , @@ -2391,7 +2391,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'voltage', 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-2-ElectricalPowerMeasurementVoltage-144-4', 'unit_of_measurement': , }) @@ -4333,7 +4333,7 @@ 'state': '0.0', }) # --- -# name: test_sensors[silabs_dishwasher][sensor.dishwasher_current-entry] +# name: test_sensors[silabs_dishwasher][sensor.dishwasher_effective_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4348,7 +4348,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.dishwasher_current', + 'entity_id': 'sensor.dishwasher_effective_current', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4366,32 +4366,91 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Current', + 'original_name': 'Effective current', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', + 'translation_key': 'rms_current', + 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-2-ElectricalPowerMeasurementRMSCurrent-144-12', 'unit_of_measurement': , }) # --- -# name: test_sensors[silabs_dishwasher][sensor.dishwasher_current-state] +# name: test_sensors[silabs_dishwasher][sensor.dishwasher_effective_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'Dishwasher Current', + 'friendly_name': 'Dishwasher Effective current', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.dishwasher_current', + 'entity_id': 'sensor.dishwasher_effective_current', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- +# name: test_sensors[silabs_dishwasher][sensor.dishwasher_effective_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': , + 'entity_id': 'sensor.dishwasher_effective_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': 'Effective voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rms_voltage', + 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-2-ElectricalPowerMeasurementRMSVoltage-144-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_dishwasher][sensor.dishwasher_effective_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Dishwasher Effective voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_effective_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120.0', + }) +# --- # name: test_sensors[silabs_dishwasher][sensor.dishwasher_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4574,65 +4633,6 @@ 'state': '0.0', }) # --- -# name: test_sensors[silabs_dishwasher][sensor.dishwasher_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': , - 'entity_id': 'sensor.dishwasher_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': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-2-ElectricalPowerMeasurementVoltage-144-4', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[silabs_dishwasher][sensor.dishwasher_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'Dishwasher Voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.dishwasher_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '120.0', - }) -# --- # name: test_sensors[silabs_evse_charging][sensor.evse_appliance_energy_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5134,65 +5134,6 @@ 'state': '32.0', }) # --- -# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.laundrywasher_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': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'current', - 'friendly_name': 'LaundryWasher Current', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.laundrywasher_current', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- # name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current_phase-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5253,6 +5194,124 @@ 'state': 'pre-soak', }) # --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_effective_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.laundrywasher_effective_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': 'Effective current', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rms_current', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementRMSCurrent-144-12', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_effective_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'LaundryWasher Effective current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_effective_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_effective_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': , + 'entity_id': 'sensor.laundrywasher_effective_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': 'Effective voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rms_voltage', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementRMSVoltage-144-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_effective_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'LaundryWasher Effective voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_effective_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120.0', + }) +# --- # name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5433,7 +5492,7 @@ 'state': '0.0', }) # --- -# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_voltage-entry] +# name: test_sensors[silabs_water_heater][sensor.water_heater_active_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5448,7 +5507,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.laundrywasher_voltage', + 'entity_id': 'sensor.water_heater_active_current', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5458,38 +5517,38 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 0, + 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': , }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Voltage', + 'original_name': 'Active current', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementVoltage-144-4', - 'unit_of_measurement': , + 'translation_key': 'active_current', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', + 'unit_of_measurement': , }) # --- -# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_voltage-state] +# name: test_sensors[silabs_water_heater][sensor.water_heater_active_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'LaundryWasher Voltage', + 'device_class': 'current', + 'friendly_name': 'Water Heater Active current', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.laundrywasher_voltage', + 'entity_id': 'sensor.water_heater_active_current', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '120.0', + 'state': '0.1', }) # --- # name: test_sensors[silabs_water_heater][sensor.water_heater_appliance_energy_state-entry] @@ -5556,65 +5615,6 @@ 'state': 'online', }) # --- -# name: test_sensors[silabs_water_heater][sensor.water_heater_current-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.water_heater_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': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[silabs_water_heater][sensor.water_heater_current-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'current', - 'friendly_name': 'Water Heater Current', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.water_heater_current', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.1', - }) -# --- # name: test_sensors[silabs_water_heater][sensor.water_heater_energy_optimization_opt_out-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5941,7 +5941,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'voltage', 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-ElectricalPowerMeasurementVoltage-144-4', 'unit_of_measurement': , }) @@ -6122,7 +6122,7 @@ 'state': '0.0', }) # --- -# name: test_sensors[solar_power][sensor.solarpower_current-entry] +# name: test_sensors[solar_power][sensor.solarpower_active_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -6137,7 +6137,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.solarpower_current', + 'entity_id': 'sensor.solarpower_active_current', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -6155,26 +6155,26 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Current', + 'original_name': 'Active current', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_current', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-ElectricalPowerMeasurementActiveCurrent-144-5', 'unit_of_measurement': , }) # --- -# name: test_sensors[solar_power][sensor.solarpower_current-state] +# name: test_sensors[solar_power][sensor.solarpower_active_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'SolarPower Current', + 'friendly_name': 'SolarPower Active current', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.solarpower_current', + 'entity_id': 'sensor.solarpower_active_current', 'last_changed': , 'last_reported': , 'last_updated': , @@ -6337,7 +6337,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'voltage', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-ElectricalPowerMeasurementVoltage-144-4', 'unit_of_measurement': , }) From 6e98446523e4ccdadd9ab6bebfb8e84610c2b3c8 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Thu, 14 Aug 2025 12:24:43 -0400 Subject: [PATCH 1019/1113] Media player API enumeration alignment and feature flags (#149597) Co-authored-by: J. Nick Koston --- .../components/esphome/media_player.py | 40 ++++++-- tests/components/esphome/test_media_player.py | 91 +++++++++++++++++++ 2 files changed, 122 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index 2d43d40bfb3..e7fcd84d299 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -10,6 +10,7 @@ from urllib.parse import urlparse from aioesphomeapi import ( EntityInfo, MediaPlayerCommand, + MediaPlayerEntityFeature as EspMediaPlayerEntityFeature, MediaPlayerEntityState, MediaPlayerFormatPurpose, MediaPlayerInfo, @@ -53,6 +54,31 @@ _STATES: EsphomeEnumMapper[EspMediaPlayerState, MediaPlayerState] = EsphomeEnumM } ) +_FEATURES = { + EspMediaPlayerEntityFeature.PAUSE: MediaPlayerEntityFeature.PAUSE, + EspMediaPlayerEntityFeature.SEEK: MediaPlayerEntityFeature.SEEK, + EspMediaPlayerEntityFeature.VOLUME_SET: MediaPlayerEntityFeature.VOLUME_SET, + EspMediaPlayerEntityFeature.VOLUME_MUTE: MediaPlayerEntityFeature.VOLUME_MUTE, + EspMediaPlayerEntityFeature.PREVIOUS_TRACK: MediaPlayerEntityFeature.PREVIOUS_TRACK, + EspMediaPlayerEntityFeature.NEXT_TRACK: MediaPlayerEntityFeature.NEXT_TRACK, + EspMediaPlayerEntityFeature.TURN_ON: MediaPlayerEntityFeature.TURN_ON, + EspMediaPlayerEntityFeature.TURN_OFF: MediaPlayerEntityFeature.TURN_OFF, + EspMediaPlayerEntityFeature.PLAY_MEDIA: MediaPlayerEntityFeature.PLAY_MEDIA, + EspMediaPlayerEntityFeature.VOLUME_STEP: MediaPlayerEntityFeature.VOLUME_STEP, + EspMediaPlayerEntityFeature.SELECT_SOURCE: MediaPlayerEntityFeature.SELECT_SOURCE, + EspMediaPlayerEntityFeature.STOP: MediaPlayerEntityFeature.STOP, + EspMediaPlayerEntityFeature.CLEAR_PLAYLIST: MediaPlayerEntityFeature.CLEAR_PLAYLIST, + EspMediaPlayerEntityFeature.PLAY: MediaPlayerEntityFeature.PLAY, + EspMediaPlayerEntityFeature.SHUFFLE_SET: MediaPlayerEntityFeature.SHUFFLE_SET, + EspMediaPlayerEntityFeature.SELECT_SOUND_MODE: MediaPlayerEntityFeature.SELECT_SOUND_MODE, + EspMediaPlayerEntityFeature.BROWSE_MEDIA: MediaPlayerEntityFeature.BROWSE_MEDIA, + EspMediaPlayerEntityFeature.REPEAT_SET: MediaPlayerEntityFeature.REPEAT_SET, + EspMediaPlayerEntityFeature.GROUPING: MediaPlayerEntityFeature.GROUPING, + EspMediaPlayerEntityFeature.MEDIA_ANNOUNCE: MediaPlayerEntityFeature.MEDIA_ANNOUNCE, + EspMediaPlayerEntityFeature.MEDIA_ENQUEUE: MediaPlayerEntityFeature.MEDIA_ENQUEUE, + EspMediaPlayerEntityFeature.SEARCH_MEDIA: MediaPlayerEntityFeature.SEARCH_MEDIA, +} + ATTR_BYPASS_PROXY = "bypass_proxy" @@ -67,16 +93,12 @@ class EsphomeMediaPlayer( def _on_static_info_update(self, static_info: EntityInfo) -> None: """Set attrs from static info.""" super()._on_static_info_update(static_info) - flags = ( - MediaPlayerEntityFeature.PLAY_MEDIA - | MediaPlayerEntityFeature.BROWSE_MEDIA - | MediaPlayerEntityFeature.STOP - | MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.VOLUME_MUTE - | MediaPlayerEntityFeature.MEDIA_ANNOUNCE + esp_flags = EspMediaPlayerEntityFeature( + self._static_info.feature_flags_compat(self._api_version) ) - if self._static_info.supports_pause: - flags |= MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.PLAY + flags = MediaPlayerEntityFeature(0) + for espflag in esp_flags: + flags |= _FEATURES[espflag] self._attr_supported_features = flags self._entry_data.media_player_formats[self.unique_id] = cast( MediaPlayerInfo, static_info diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index 232f7e1f06e..efc060bb136 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -29,6 +29,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, + STATE_PLAYING, BrowseMedia, MediaClass, MediaType, @@ -56,6 +57,8 @@ async def test_media_player_entity( key=1, name="my media_player", supports_pause=True, + # PLAY_MEDIA,BROWSE_MEDIA,STOP,VOLUME_SET,VOLUME_MUTE,MEDIA_ANNOUNCE,PAUSE,PLAY + feature_flags=1200653, ) ] states = [ @@ -156,6 +159,88 @@ async def test_media_player_entity( mock_client.media_player_command.reset_mock() +async def test_media_player_entity_with_undefined_flags( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test that media_player handles undefined feature flags gracefully.""" + # Include existing flags (PAUSE=1, PLAY=16384, VOLUME_SET=4) + # plus undefined bits (bit 6=64, bit 23=8388608) + # Total: 1 + 16384 + 4 + 64 + 8388608 = 8405061 + entity_info = [ + MediaPlayerInfo( + object_id="mymedia_player_undefined", + key=1, + name="my media_player undefined", + supports_pause=True, + # PAUSE,PLAY,VOLUME_SET + undefined bits 6 and 23 + feature_flags=8405061, + ) + ] + states = [ + MediaPlayerEntityState( + key=1, volume=50, muted=False, state=MediaPlayerState.PLAYING + ) + ] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + + # Verify entity is created successfully despite undefined flags + state = hass.states.get("media_player.test_my_media_player_undefined") + assert state is not None + assert state.state == STATE_PLAYING + + # Verify supported features only include known flags + # Should have PAUSE, PLAY, and VOLUME_SET + supported_features = state.attributes.get("supported_features", 0) + # PAUSE=1, VOLUME_SET=4, PLAY=16384 = 16389 + assert supported_features == 16389 + + # Verify entity works correctly with known features + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PLAY, + { + ATTR_ENTITY_ID: "media_player.test_my_media_player_undefined", + }, + blocking=True, + ) + mock_client.media_player_command.assert_has_calls( + [call(1, command=MediaPlayerCommand.PLAY, device_id=0)] + ) + mock_client.media_player_command.reset_mock() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PAUSE, + { + ATTR_ENTITY_ID: "media_player.test_my_media_player_undefined", + }, + blocking=True, + ) + mock_client.media_player_command.assert_has_calls( + [call(1, command=MediaPlayerCommand.PAUSE, device_id=0)] + ) + mock_client.media_player_command.reset_mock() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + { + ATTR_ENTITY_ID: "media_player.test_my_media_player_undefined", + ATTR_MEDIA_VOLUME_LEVEL: 0.7, + }, + blocking=True, + ) + mock_client.media_player_command.assert_has_calls( + [call(1, volume=0.7, device_id=0)] + ) + + async def test_media_player_entity_with_source( hass: HomeAssistant, mock_client: APIClient, @@ -202,6 +287,8 @@ async def test_media_player_entity_with_source( key=1, name="my media_player", supports_pause=True, + # PLAY_MEDIA,BROWSE_MEDIA,STOP,VOLUME_SET,VOLUME_MUTE,MEDIA_ANNOUNCE,PAUSE,PLAY + feature_flags=1200653, ) ] states = [ @@ -317,6 +404,8 @@ async def test_media_player_proxy( key=1, name="my media_player", supports_pause=True, + # PLAY_MEDIA,BROWSE_MEDIA,STOP,VOLUME_SET,VOLUME_MUTE,MEDIA_ANNOUNCE,PAUSE,PLAY + feature_flags=1200653, supported_formats=[ MediaPlayerSupportedFormat( format="flac", @@ -475,6 +564,8 @@ async def test_media_player_formats_reload_preserves_data( key=1, name="Test Media Player", supports_pause=True, + # PLAY_MEDIA,BROWSE_MEDIA,STOP,VOLUME_SET,VOLUME_MUTE,MEDIA_ANNOUNCE,PAUSE,PLAY + feature_flags=1200653, supported_formats=supported_formats, ) ], From 1ea740d81cf974be3a5b3421d84751b9b2d4fbd9 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Thu, 14 Aug 2025 13:07:01 -0400 Subject: [PATCH 1020/1113] Add media_player add off on capability to esphome (#147990) --- .../components/esphome/media_player.py | 20 ++++++++++++ tests/components/esphome/test_media_player.py | 31 +++++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index e7fcd84d299..a35d93c9fe1 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -51,6 +51,8 @@ _STATES: EsphomeEnumMapper[EspMediaPlayerState, MediaPlayerState] = EsphomeEnumM EspMediaPlayerState.IDLE: MediaPlayerState.IDLE, EspMediaPlayerState.PLAYING: MediaPlayerState.PLAYING, EspMediaPlayerState.PAUSED: MediaPlayerState.PAUSED, + EspMediaPlayerState.OFF: MediaPlayerState.OFF, + EspMediaPlayerState.ON: MediaPlayerState.ON, } ) @@ -279,6 +281,24 @@ class EsphomeMediaPlayer( device_id=self._static_info.device_id, ) + @convert_api_error_ha_error + async def async_turn_on(self) -> None: + """Send turn on command.""" + self._client.media_player_command( + self._key, + command=MediaPlayerCommand.TURN_ON, + device_id=self._static_info.device_id, + ) + + @convert_api_error_ha_error + async def async_turn_off(self) -> None: + """Send turn off command.""" + self._client.media_player_command( + self._key, + command=MediaPlayerCommand.TURN_OFF, + device_id=self._static_info.device_id, + ) + def _is_url(url: str) -> bool: """Validate the URL can be parsed and at least has scheme + netloc.""" diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index efc060bb136..b5805298b97 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -27,6 +27,8 @@ from homeassistant.components.media_player import ( SERVICE_MEDIA_PLAY, SERVICE_MEDIA_STOP, SERVICE_PLAY_MEDIA, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, STATE_PLAYING, @@ -57,8 +59,8 @@ async def test_media_player_entity( key=1, name="my media_player", supports_pause=True, - # PLAY_MEDIA,BROWSE_MEDIA,STOP,VOLUME_SET,VOLUME_MUTE,MEDIA_ANNOUNCE,PAUSE,PLAY - feature_flags=1200653, + # PLAY_MEDIA,BROWSE_MEDIA,STOP,VOLUME_SET,VOLUME_MUTE,MEDIA_ANNOUNCE,PAUSE,PLAY,TURN_OFF,TURN_ON + feature_flags=1201037, ) ] states = [ @@ -158,6 +160,31 @@ async def test_media_player_entity( ) mock_client.media_player_command.reset_mock() + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "media_player.test_my_media_player", + }, + blocking=True, + ) + mock_client.media_player_command.assert_has_calls( + [call(1, command=MediaPlayerCommand.TURN_OFF, device_id=0)] + ) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "media_player.test_my_media_player", + }, + blocking=True, + ) + mock_client.media_player_command.assert_has_calls( + [call(1, command=MediaPlayerCommand.TURN_ON, device_id=0)] + ) + mock_client.media_player_command.reset_mock() + async def test_media_player_entity_with_undefined_flags( hass: HomeAssistant, From 9c21965a3430d7141b02c2f9eba5373d97b5c2ea Mon Sep 17 00:00:00 2001 From: DeerMaximum <43999966+DeerMaximum@users.noreply.github.com> Date: Thu, 14 Aug 2025 19:57:33 +0200 Subject: [PATCH 1021/1113] Add diagnostics to NINA (#150638) --- homeassistant/components/nina/diagnostics.py | 24 ++++++++++ .../nina/snapshots/test_diagnostics.ambr | 45 +++++++++++++++++++ tests/components/nina/test_diagnostics.py | 45 +++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 homeassistant/components/nina/diagnostics.py create mode 100644 tests/components/nina/snapshots/test_diagnostics.ambr create mode 100644 tests/components/nina/test_diagnostics.py diff --git a/homeassistant/components/nina/diagnostics.py b/homeassistant/components/nina/diagnostics.py new file mode 100644 index 00000000000..f62b7b6bcec --- /dev/null +++ b/homeassistant/components/nina/diagnostics.py @@ -0,0 +1,24 @@ +"""Diagnostics for the Nina integration.""" + +from dataclasses import asdict +from typing import Any + +from homeassistant.core import HomeAssistant + +from .coordinator import NinaConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: NinaConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + runtime_data_dict = { + region_key: [asdict(warning) for warning in region_data] + for region_key, region_data in entry.runtime_data.data.items() + } + + return { + "entry_data": dict(entry.data), + "data": runtime_data_dict, + } diff --git a/tests/components/nina/snapshots/test_diagnostics.ambr b/tests/components/nina/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..aaf42471912 --- /dev/null +++ b/tests/components/nina/snapshots/test_diagnostics.ambr @@ -0,0 +1,45 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + '083350000000': list([ + dict({ + 'affected_areas': 'Gemeinde Oberreichenbach, Gemeinde Neuweiler, Stadt Nagold, Stadt Neubulach, Gemeinde Schömberg, Gemeinde Simmersfeld, Gemeinde Simmozheim, Gemeinde Rohrdorf, Gemeinde Ostelsheim, Gemeinde Ebhausen, Gemeinde Egenhausen, Gemeinde Dobel, Stadt Bad Liebenzell, Stadt Solingen, Stadt Haiterbach, Stadt Bad Herrenalb, Gemeinde Höfen an der Enz, Gemeinde Gechingen, Gemeinde Enzklösterle, Gemeinde Gutach (Schwarzwaldbahn) und 3392 weitere.', + 'description': 'Es treten Sturmböen mit Geschwindigkeiten zwischen 70 km/h (20m/s, 38kn, Bft 8) und 85 km/h (24m/s, 47kn, Bft 9) aus westlicher Richtung auf. In Schauernähe sowie in exponierten Lagen muss mit schweren Sturmböen bis 90 km/h (25m/s, 48kn, Bft 10) gerechnet werden.', + 'expires': '3021-11-22T05:19:00+01:00', + 'headline': 'Ausfall Notruf 112', + 'id': 'mow.DE-NW-BN-SE030-20201014-30-000', + 'is_valid': True, + 'recommended_actions': '', + 'sender': 'Deutscher Wetterdienst', + 'sent': '2021-10-11T05:20:00+01:00', + 'severity': 'Minor', + 'start': '2021-11-01T05:20:00+01:00', + 'web': 'https://www.wettergefahren.de', + }), + dict({ + 'affected_areas': 'Axstedt, Gnarrenburg, Grasberg, Hagen im Bremischen, Hambergen, Hepstedt, Holste, Lilienthal, Lübberstedt, Osterholz-Scharmbeck, Ritterhude, Schwanewede, Vollersode, Worpswede', + 'description': 'In Beverstedt im Landkreis Cuxhaven ist am 20. Juli 2022 in einer Geflügelhaltung der Ausbruch der Geflügelpest (Vogelgrippe, Aviäre Influenza) amtlich festgestellt worden. Durch die geografische Nähe des Ausbruchsbetriebes zum Gebiet des Landkreises Osterholz musste das Veterinäramt des Landkreises zum Schutz vor einer Ausbreitung der Geflügelpest auch für sein Gebiet ein Restriktionsgebiet festlegen. Rund um den Ausbruchsort wurde eine Überwachungszone ausgewiesen. Eine entsprechende Tierseuchenbehördliche Allgemeinverfügung wurde vom Landkreis Osterholz erlassen und tritt am 23.07.2022 in Kraft.
\xa0
Die Überwachungszone mit einem Radius von mindestens zehn Kilometern um den Ausbruchsbetrieb erstreckt sich im Landkreis Osterholz innerhalb der Samtgemeinde Hambergen auf die Mitgliedsgemeinden Axstedt, Holste und Lübberstedt. Die vorgenannten Gemeinden sind vollständig zur Überwachungszone erklärt worden. Der genaue Grenzverlauf des Gebietes kann auch der interaktiven Karte im Internet entnommen werden.
\xa0
In der Überwachungszone liegen im Landkreis Osterholz rund 70 Geflügelhaltungen mit einem Gesamtbestand von rund 1.800 Tieren. Sie alle unterliegen mit der Allgemeinverfügung der sogenannten amtlichen Beobachtung. Für die Betriebe sind die Biosicherheitsmaßnahmen einzuhalten. Dazu zählen insbesondere Hygienemaßnahmen im laufenden Betrieb und eine ordnungsgemäße Schadnagerbekämpfung.
\xa0
Das Verbringen von Vögeln, Fleisch von Geflügel, Eiern und sonstige Nebenprodukte von Geflügel in und aus Betrieben in der Überwachungszone ist verboten. Auch Geflügeltransporte sind in der Überwachungszone verboten. Jeder Verdacht der Erkrankung auf Geflügelpest ist zudem dem Veterinäramt des Landkreises Osterholz unter der E-Mail-Adresse veterinaeramt@landkreis-osterholz.de sofort zu melden. Alle Hinweise, die innerhalb der Überwachungszone zu beachten sind, sind unter www.landkreis-osterholz.de/gefluegelpest zusammengefasst dargestellt.
\xa0
Die Veterinärbehörde weist zudem darauf hin, dass sämtliche Geflügelhaltungen – Hühner, Enten, Gänse, Fasane, Perlhühner, Rebhühner, Truthühner, Wachteln oder Laufvögel – der zuständigen Behörde angezeigt werden müssen. Wer dies bisher noch nicht gemacht hat und über keine Registriernummer für seinen Geflügelbestand verfügt, sollte die Meldung über das Veterinäramt umgehend nachholen.
\xa0
Das Beobachtungsgebiet kann frühestens 30 Tage nach der Grobreinigung des Ausbruchsbetriebes wieder aufgehoben werden. Hierüber wird der Landkreis Osterholz informieren.
\xa0
Die Allgemeinverfügung, eine Übersicht zur Überwachungszone und weitere Hinweise sind auf der Internetseite unter www.landkreis-osterholz.de/gefluegelpest zu finden.', + 'expires': '2002-08-07T10:59:00+02:00', + 'headline': 'Geflügelpest im Landkreis Cuxhaven - Teile des Landkreises Osterholz zur Überwachungszone erklärt', + 'id': 'biw.BIWAPP-69634', + 'is_valid': False, + 'recommended_actions': '', + 'sender': '', + 'sent': '1999-08-07T10:59:00+02:00', + 'severity': 'Minor', + 'start': '', + 'web': '', + }), + ]), + }), + 'entry_data': dict({ + 'area_filter': '.*', + 'headline_filter': '.*corona.*', + 'regions': dict({ + '083350000000': 'Aach, Stadt', + }), + 'slots': 5, + }), + }) +# --- diff --git a/tests/components/nina/test_diagnostics.py b/tests/components/nina/test_diagnostics.py new file mode 100644 index 00000000000..c0646b8d68c --- /dev/null +++ b/tests/components/nina/test_diagnostics.py @@ -0,0 +1,45 @@ +"""Test the Nina diagnostics.""" + +from typing import Any +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.nina.const import DOMAIN +from homeassistant.core import HomeAssistant + +from . import mocked_request_function + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + +ENTRY_DATA: dict[str, Any] = { + "slots": 5, + "corona_filter": True, + "regions": {"083350000000": "Aach, Stadt"}, +} + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + wraps=mocked_request_function, + ): + config_entry: MockConfigEntry = MockConfigEntry( + domain=DOMAIN, title="NINA", data=ENTRY_DATA + ) + + 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 7e6ceee9d14ed64355a8713029f08a7ba584ae80 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Thu, 14 Aug 2025 22:34:37 +0200 Subject: [PATCH 1022/1113] Add IQ Meter Collar and C6 Combiner to enphase_envoy integration (#150649) --- .../components/enphase_envoy/binary_sensor.py | 116 +++++++- .../components/enphase_envoy/sensor.py | 130 +++++++++ .../components/enphase_envoy/strings.json | 6 + tests/components/enphase_envoy/conftest.py | 6 + .../fixtures/envoy_metered_batt_relay.json | 29 ++ .../snapshots/test_binary_sensor.ambr | 98 +++++++ .../enphase_envoy/snapshots/test_sensor.ambr | 247 ++++++++++++++++++ 7 files changed, 631 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/enphase_envoy/binary_sensor.py b/homeassistant/components/enphase_envoy/binary_sensor.py index 2628406f56f..5dcc2f28c7f 100644 --- a/homeassistant/components/enphase_envoy/binary_sensor.py +++ b/homeassistant/components/enphase_envoy/binary_sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from operator import attrgetter -from pyenphase import EnvoyEncharge, EnvoyEnpower +from pyenphase import EnvoyC6CC, EnvoyCollar, EnvoyEncharge, EnvoyEnpower from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -72,6 +72,42 @@ ENPOWER_SENSORS = ( ) +@dataclass(frozen=True, kw_only=True) +class EnvoyCollarBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes an Envoy IQ Meter Collar binary sensor entity.""" + + value_fn: Callable[[EnvoyCollar], bool] + + +COLLAR_SENSORS = ( + EnvoyCollarBinarySensorEntityDescription( + key="communicating", + translation_key="communicating", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=attrgetter("communicating"), + ), +) + + +@dataclass(frozen=True, kw_only=True) +class EnvoyC6CCBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes an C6 Combiner controller binary sensor entity.""" + + value_fn: Callable[[EnvoyC6CC], bool] + + +C6CC_SENSORS = ( + EnvoyC6CCBinarySensorEntityDescription( + key="communicating", + translation_key="communicating", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=attrgetter("communicating"), + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: EnphaseConfigEntry, @@ -95,6 +131,18 @@ async def async_setup_entry( for description in ENPOWER_SENSORS ) + if envoy_data.collar: + entities.extend( + EnvoyCollarBinarySensorEntity(coordinator, description) + for description in COLLAR_SENSORS + ) + + if envoy_data.c6cc: + entities.extend( + EnvoyC6CCBinarySensorEntity(coordinator, description) + for description in C6CC_SENSORS + ) + async_add_entities(entities) @@ -168,3 +216,69 @@ class EnvoyEnpowerBinarySensorEntity(EnvoyBaseBinarySensorEntity): enpower = self.data.enpower assert enpower is not None return self.entity_description.value_fn(enpower) + + +class EnvoyCollarBinarySensorEntity(EnvoyBaseBinarySensorEntity): + """Defines an IQ Meter Collar binary_sensor entity.""" + + entity_description: EnvoyCollarBinarySensorEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyCollarBinarySensorEntityDescription, + ) -> None: + """Init the Collar base entity.""" + super().__init__(coordinator, description) + collar_data = self.data.collar + assert collar_data is not None + self._attr_unique_id = f"{collar_data.serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, collar_data.serial_number)}, + manufacturer="Enphase", + model="IQ Meter Collar", + name=f"Collar {collar_data.serial_number}", + sw_version=str(collar_data.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + serial_number=collar_data.serial_number, + ) + + @property + def is_on(self) -> bool: + """Return the state of the Collar binary_sensor.""" + collar_data = self.data.collar + assert collar_data is not None + return self.entity_description.value_fn(collar_data) + + +class EnvoyC6CCBinarySensorEntity(EnvoyBaseBinarySensorEntity): + """Defines an C6 Combiner binary_sensor entity.""" + + entity_description: EnvoyC6CCBinarySensorEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyC6CCBinarySensorEntityDescription, + ) -> None: + """Init the C6 Combiner base entity.""" + super().__init__(coordinator, description) + c6cc_data = self.data.c6cc + assert c6cc_data is not None + self._attr_unique_id = f"{c6cc_data.serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, c6cc_data.serial_number)}, + manufacturer="Enphase", + model="C6 COMBINER CONTROLLER", + name=f"C6 Combiner {c6cc_data.serial_number}", + sw_version=str(c6cc_data.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + serial_number=c6cc_data.serial_number, + ) + + @property + def is_on(self) -> bool: + """Return the state of the C6 Combiner binary_sensor.""" + c6cc_data = self.data.c6cc + assert c6cc_data is not None + return self.entity_description.value_fn(c6cc_data) diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 63a2a09a6f5..e771233b069 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -12,6 +12,8 @@ from typing import TYPE_CHECKING from pyenphase import ( EnvoyACBPower, EnvoyBatteryAggregate, + EnvoyC6CC, + EnvoyCollar, EnvoyEncharge, EnvoyEnchargeAggregate, EnvoyEnchargePower, @@ -790,6 +792,58 @@ ENPOWER_SENSORS = ( ) +@dataclass(frozen=True, kw_only=True) +class EnvoyCollarSensorEntityDescription(SensorEntityDescription): + """Describes an Envoy Collar sensor entity.""" + + value_fn: Callable[[EnvoyCollar], datetime.datetime | int | float | str] + + +COLLAR_SENSORS = ( + EnvoyCollarSensorEntityDescription( + key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + value_fn=attrgetter("temperature"), + ), + EnvoyCollarSensorEntityDescription( + key=LAST_REPORTED_KEY, + translation_key=LAST_REPORTED_KEY, + native_unit_of_measurement=None, + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda collar: dt_util.utc_from_timestamp(collar.last_report_date), + ), + EnvoyCollarSensorEntityDescription( + key="grid_state", + translation_key="grid_status", + value_fn=lambda collar: collar.grid_state, + ), + EnvoyCollarSensorEntityDescription( + key="mid_state", + translation_key="mid_state", + value_fn=lambda collar: collar.mid_state, + ), +) + + +@dataclass(frozen=True, kw_only=True) +class EnvoyC6CCSensorEntityDescription(SensorEntityDescription): + """Describes an Envoy C6 Combiner controller sensor entity.""" + + value_fn: Callable[[EnvoyC6CC], datetime.datetime] + + +C6CC_SENSORS = ( + EnvoyC6CCSensorEntityDescription( + key=LAST_REPORTED_KEY, + translation_key=LAST_REPORTED_KEY, + native_unit_of_measurement=None, + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda c6cc: dt_util.utc_from_timestamp(c6cc.last_report_date), + ), +) + + @dataclass(frozen=True) class EnvoyEnchargeAggregateRequiredKeysMixin: """Mixin for required keys.""" @@ -1050,6 +1104,15 @@ async def async_setup_entry( AggregateBatteryEntity(coordinator, description) for description in AGGREGATE_BATTERY_SENSORS ) + if envoy_data.collar: + entities.extend( + EnvoyCollarEntity(coordinator, description) + for description in COLLAR_SENSORS + ) + if envoy_data.c6cc: + entities.extend( + EnvoyC6CCEntity(coordinator, description) for description in C6CC_SENSORS + ) async_add_entities(entities) @@ -1488,3 +1551,70 @@ class AggregateBatteryEntity(EnvoySystemSensorEntity): battery_aggregate = self.data.battery_aggregate assert battery_aggregate is not None return self.entity_description.value_fn(battery_aggregate) + + +class EnvoyCollarEntity(EnvoySensorBaseEntity): + """Envoy Collar sensor entity.""" + + entity_description: EnvoyCollarSensorEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyCollarSensorEntityDescription, + ) -> None: + """Initialize Collar entity.""" + super().__init__(coordinator, description) + collar_data = self.data.collar + assert collar_data is not None + self._serial_number = collar_data.serial_number + self._attr_unique_id = f"{collar_data.serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, collar_data.serial_number)}, + manufacturer="Enphase", + model="IQ Meter Collar", + name=f"Collar {collar_data.serial_number}", + sw_version=str(collar_data.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + serial_number=collar_data.serial_number, + ) + + @property + def native_value(self) -> datetime.datetime | int | float | str: + """Return the state of the collar sensors.""" + collar_data = self.data.collar + assert collar_data is not None + return self.entity_description.value_fn(collar_data) + + +class EnvoyC6CCEntity(EnvoySensorBaseEntity): + """Envoy C6CC sensor entity.""" + + entity_description: EnvoyC6CCSensorEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyC6CCSensorEntityDescription, + ) -> None: + """Initialize Encharge entity.""" + super().__init__(coordinator, description) + c6cc_data = self.data.c6cc + assert c6cc_data is not None + self._attr_unique_id = f"{c6cc_data.serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, c6cc_data.serial_number)}, + manufacturer="Enphase", + model="C6 COMBINER CONTROLLER", + name=f"C6 Combiner {c6cc_data.serial_number}", + sw_version=str(c6cc_data.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + serial_number=c6cc_data.serial_number, + ) + + @property + def native_value(self) -> datetime.datetime: + """Return the state of the c6cc inventory sensors.""" + c6cc_data = self.data.c6cc + assert c6cc_data is not None + return self.entity_description.value_fn(c6cc_data) diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index ffe0ccb1271..17ed8eff67e 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -407,6 +407,12 @@ }, "last_report_duration": { "name": "Last report duration" + }, + "grid_status": { + "name": "[%key:component::enphase_envoy::entity::binary_sensor::grid_status::name%]" + }, + "mid_state": { + "name": "MID state" } }, "switch": { diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index 7ad15f85ac2..9e94dab5a4c 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -9,6 +9,8 @@ import multidict from pyenphase import ( EnvoyACBPower, EnvoyBatteryAggregate, + EnvoyC6CC, + EnvoyCollar, EnvoyData, EnvoyEncharge, EnvoyEnchargeAggregate, @@ -260,6 +262,10 @@ def _load_json_2_encharge_enpower_data( ) if item := json_fixture["data"].get("battery_aggregate"): mocked_data.battery_aggregate = EnvoyBatteryAggregate(**item) + if item := json_fixture["data"].get("collar"): + mocked_data.collar = EnvoyCollar(**item) + if item := json_fixture["data"].get("c6cc"): + mocked_data.c6cc = EnvoyC6CC(**item) def _load_json_2_raw_data(mocked_data: EnvoyData, json_fixture: dict[str, Any]) -> None: diff --git a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json index 73af5af0e5d..e8e0fd8ac85 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json +++ b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json @@ -407,6 +407,35 @@ "type": "NONE" } }, + "collar": { + "admin_state": 88, + "admin_state_str": "ENCMN_MDE_ON_GRID", + "firmware_loaded_date": 1752939759, + "firmware_version": "3.0.6-D0", + "installed_date": 1752939759, + "last_report_date": 1752939759, + "communicating": true, + "mid_state": "close", + "grid_state": "on_grid", + "part_number": "865-00400-r22", + "serial_number": "482520020939", + "temperature": 42, + "temperature_unit": "C", + "control_error": 0, + "collar_state": "Installed" + }, + "c6cc": { + "admin_state": 82, + "admin_state_str": "ENCMN_C6_CC_READY", + "firmware_loaded_date": 1752945451, + "firmware_version": "0.1.20-D1", + "installed_date": 1752945451, + "last_report_date": 1752945451, + "communicating": true, + "part_number": "800-02403-r08", + "serial_number": "482523040549", + "dmir_version": "0.1.20-D1" + }, "inverters": { "1": { "serial_number": "1", diff --git a/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr index bbf35621c6c..18e7a9c9008 100644 --- a/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr @@ -96,6 +96,104 @@ 'state': 'on', }) # --- +# name: test_binary_sensor[envoy_metered_batt_relay][binary_sensor.c6_combiner_482523040549_communicating-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.c6_combiner_482523040549_communicating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Communicating', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'communicating', + 'unique_id': '482523040549_communicating', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[envoy_metered_batt_relay][binary_sensor.c6_combiner_482523040549_communicating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'C6 Combiner 482523040549 Communicating', + }), + 'context': , + 'entity_id': 'binary_sensor.c6_combiner_482523040549_communicating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[envoy_metered_batt_relay][binary_sensor.collar_482520020939_communicating-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.collar_482520020939_communicating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Communicating', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'communicating', + 'unique_id': '482520020939_communicating', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[envoy_metered_batt_relay][binary_sensor.collar_482520020939_communicating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Collar 482520020939 Communicating', + }), + 'context': , + 'entity_id': 'binary_sensor.collar_482520020939_communicating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensor[envoy_metered_batt_relay][binary_sensor.encharge_123456_communicating-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index 4a9563ce906..00cb30fce09 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -13947,6 +13947,253 @@ 'state': 'unknown', }) # --- +# name: test_sensor[envoy_metered_batt_relay][sensor.c6_combiner_482523040549_last_reported-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.c6_combiner_482523040549_last_reported', + '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 reported', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_reported', + 'unique_id': '482523040549_last_reported', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.c6_combiner_482523040549_last_reported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'C6 Combiner 482523040549 Last reported', + }), + 'context': , + 'entity_id': 'sensor.c6_combiner_482523040549_last_reported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-07-19T17:17:31+00:00', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_grid_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.collar_482520020939_grid_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': 'Grid status', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_status', + 'unique_id': '482520020939_grid_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_grid_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Collar 482520020939 Grid status', + }), + 'context': , + 'entity_id': 'sensor.collar_482520020939_grid_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on_grid', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_last_reported-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.collar_482520020939_last_reported', + '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 reported', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_reported', + 'unique_id': '482520020939_last_reported', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_last_reported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Collar 482520020939 Last reported', + }), + 'context': , + 'entity_id': 'sensor.collar_482520020939_last_reported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-07-19T15:42:39+00:00', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_mid_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.collar_482520020939_mid_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': 'MID state', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mid_state', + 'unique_id': '482520020939_mid_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_mid_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Collar 482520020939 MID state', + }), + 'context': , + 'entity_id': 'sensor.collar_482520020939_mid_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'close', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.collar_482520020939_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': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '482520020939_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Collar 482520020939 Temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.collar_482520020939_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- # name: test_sensor[envoy_metered_batt_relay][sensor.encharge_123456_apparent_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From f5fe53a67f2c0105ede5110591daa58294ecf361 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 16:16:04 -0500 Subject: [PATCH 1023/1113] Bump uiprotect to 7.21.1 (#150657) --- 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 8eee080abb4..50bdeec8572 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.20.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.21.1", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 2b4c6c916bb..2ce816f94c8 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.20.0 +uiprotect==7.21.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e11345951b..bdc736ec63a 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.20.0 +uiprotect==7.21.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 57265ac6485ad400ed6f1dc128bf4db36b75002a Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 14 Aug 2025 16:28:42 -0500 Subject: [PATCH 1024/1113] Add fuzzy matching to default agent (#150595) --- .../components/conversation/__init__.py | 5 +- .../components/conversation/default_agent.py | 378 ++++++++++++++---- homeassistant/components/conversation/http.py | 29 +- tests/components/assist_pipeline/conftest.py | 7 +- tests/components/conversation/conftest.py | 6 +- .../conversation/snapshots/test_http.ambr | 13 +- .../conversation/test_default_agent.py | 105 ++++- 7 files changed, 435 insertions(+), 108 deletions(-) diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 3435a7d2ed4..4fd3a57034f 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -117,7 +117,7 @@ CONFIG_SCHEMA = vol.Schema( {cv.string: vol.All(cv.ensure_list, [cv.string])} ) } - ) + ), }, extra=vol.ALLOW_EXTRA, ) @@ -268,8 +268,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass) hass.data[DATA_COMPONENT] = entity_component + agent_config = config.get(DOMAIN, {}) await async_setup_default_agent( - hass, entity_component, config.get(DOMAIN, {}).get("intents", {}) + hass, entity_component, config_intents=agent_config.get("intents", {}) ) async def handle_process(service: ServiceCall) -> ServiceResponse: diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 3fb305098e7..4b056ead2c2 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -15,13 +15,18 @@ import time from typing import IO, Any, cast from hassil.expression import Expression, Group, ListReference, TextChunk +from hassil.fuzzy import FuzzyNgramMatcher, SlotCombinationInfo from hassil.intents import ( + Intent, + IntentData, Intents, SlotList, TextSlotList, TextSlotValue, WildcardSlotList, ) +from hassil.models import MatchEntity +from hassil.ngram import Sqlite3NgramModel from hassil.recognize import ( MISSING_ENTITY, RecognizeResult, @@ -31,7 +36,15 @@ from hassil.recognize import ( from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity from hassil.trie import Trie from hassil.util import merge_dict -from home_assistant_intents import ErrorKey, get_intents, get_languages +from home_assistant_intents import ( + ErrorKey, + FuzzyConfig, + FuzzyLanguageResponses, + get_fuzzy_config, + get_fuzzy_language, + get_intents, + get_languages, +) import yaml from homeassistant import core @@ -76,6 +89,7 @@ TRIGGER_CALLBACK_TYPE = Callable[ ] METADATA_CUSTOM_SENTENCE = "hass_custom_sentence" METADATA_CUSTOM_FILE = "hass_custom_file" +METADATA_FUZZY_MATCH = "hass_fuzzy_match" ERROR_SENTINEL = object() @@ -94,6 +108,8 @@ class LanguageIntents: intent_responses: dict[str, Any] error_responses: dict[str, Any] language_variant: str | None + fuzzy_matcher: FuzzyNgramMatcher | None = None + fuzzy_responses: FuzzyLanguageResponses | None = None @dataclass(slots=True) @@ -119,10 +135,13 @@ class IntentMatchingStage(Enum): EXPOSED_ENTITIES_ONLY = auto() """Match against exposed entities only.""" + FUZZY = auto() + """Use fuzzy matching to guess intent.""" + UNEXPOSED_ENTITIES = auto() """Match against unexposed entities in Home Assistant.""" - FUZZY = auto() + UNKNOWN_NAMES = auto() """Capture names that are not known to Home Assistant.""" @@ -241,6 +260,10 @@ class DefaultAgent(ConversationEntity): # LRU cache to avoid unnecessary intent matching self._intent_cache = IntentCache(capacity=128) + # Shared configuration for fuzzy matching + self.fuzzy_matching = True + self._fuzzy_config: FuzzyConfig | None = None + @property def supported_languages(self) -> list[str]: """Return a list of supported languages.""" @@ -299,7 +322,7 @@ class DefaultAgent(ConversationEntity): _LOGGER.warning("No intents were loaded for language: %s", language) return None - slot_lists = self._make_slot_lists() + slot_lists = await self._make_slot_lists() intent_context = self._make_intent_context(user_input) if self._exposed_names_trie is not None: @@ -556,6 +579,36 @@ class DefaultAgent(ConversationEntity): # Don't try matching against all entities or doing a fuzzy match return None + # Use fuzzy matching + skip_fuzzy_match = False + if cache_value is not None: + if (cache_value.result is not None) and ( + cache_value.stage == IntentMatchingStage.FUZZY + ): + _LOGGER.debug("Got cached result for fuzzy match") + return cache_value.result + + # Continue with matching, but we know we won't succeed for fuzzy + # match. + skip_fuzzy_match = True + + if (not skip_fuzzy_match) and self.fuzzy_matching: + start_time = time.monotonic() + fuzzy_result = self._recognize_fuzzy(lang_intents, user_input) + + # Update cache + self._intent_cache.put( + cache_key, + IntentCacheValue(result=fuzzy_result, stage=IntentMatchingStage.FUZZY), + ) + + _LOGGER.debug( + "Did fuzzy match in %s second(s)", time.monotonic() - start_time + ) + + if fuzzy_result is not None: + return fuzzy_result + # Try again with all entities (including unexposed) skip_unexposed_entities_match = False if cache_value is not None: @@ -601,102 +654,160 @@ class DefaultAgent(ConversationEntity): # This should fail the intent handling phase (async_match_targets). return strict_result - # Try again with missing entities enabled - skip_fuzzy_match = False + # Check unknown names + skip_unknown_names = False if cache_value is not None: if (cache_value.result is not None) and ( - cache_value.stage == IntentMatchingStage.FUZZY + cache_value.stage == IntentMatchingStage.UNKNOWN_NAMES ): - _LOGGER.debug("Got cached result for fuzzy match") + _LOGGER.debug("Got cached result for unknown names") return cache_value.result - # We know we won't succeed for fuzzy matching. - skip_fuzzy_match = True + skip_unknown_names = True maybe_result: RecognizeResult | None = None - if not skip_fuzzy_match: + if not skip_unknown_names: start_time = time.monotonic() - best_num_matched_entities = 0 - best_num_unmatched_entities = 0 - best_num_unmatched_ranges = 0 - for result in recognize_all( - user_input.text, - lang_intents.intents, - slot_lists=slot_lists, - intent_context=intent_context, - allow_unmatched_entities=True, - ): - if result.text_chunks_matched < 1: - # Skip results that don't match any literal text - continue - - # Don't count missing entities that couldn't be filled from context - num_matched_entities = 0 - for matched_entity in result.entities_list: - if matched_entity.name not in result.unmatched_entities: - num_matched_entities += 1 - - num_unmatched_entities = 0 - num_unmatched_ranges = 0 - for unmatched_entity in result.unmatched_entities_list: - if isinstance(unmatched_entity, UnmatchedTextEntity): - if unmatched_entity.text != MISSING_ENTITY: - num_unmatched_entities += 1 - elif isinstance(unmatched_entity, UnmatchedRangeEntity): - num_unmatched_ranges += 1 - num_unmatched_entities += 1 - else: - num_unmatched_entities += 1 - - if ( - (maybe_result is None) # first result - or ( - # More literal text matched - result.text_chunks_matched > maybe_result.text_chunks_matched - ) - or ( - # More entities matched - num_matched_entities > best_num_matched_entities - ) - or ( - # Fewer unmatched entities - (num_matched_entities == best_num_matched_entities) - and (num_unmatched_entities < best_num_unmatched_entities) - ) - or ( - # Prefer unmatched ranges - (num_matched_entities == best_num_matched_entities) - and (num_unmatched_entities == best_num_unmatched_entities) - and (num_unmatched_ranges > best_num_unmatched_ranges) - ) - or ( - # Prefer match failures with entities - (result.text_chunks_matched == maybe_result.text_chunks_matched) - and (num_unmatched_entities == best_num_unmatched_entities) - and (num_unmatched_ranges == best_num_unmatched_ranges) - and ( - ("name" in result.entities) - or ("name" in result.unmatched_entities) - ) - ) - ): - maybe_result = result - best_num_matched_entities = num_matched_entities - best_num_unmatched_entities = num_unmatched_entities - best_num_unmatched_ranges = num_unmatched_ranges + maybe_result = self._recognize_unknown_names( + lang_intents, user_input, slot_lists, intent_context + ) # Update cache self._intent_cache.put( cache_key, - IntentCacheValue(result=maybe_result, stage=IntentMatchingStage.FUZZY), + IntentCacheValue( + result=maybe_result, stage=IntentMatchingStage.UNKNOWN_NAMES + ), ) _LOGGER.debug( - "Did fuzzy match in %s second(s)", time.monotonic() - start_time + "Did unknown names match in %s second(s)", time.monotonic() - start_time ) return maybe_result + def _recognize_fuzzy( + self, lang_intents: LanguageIntents, user_input: ConversationInput + ) -> RecognizeResult | None: + """Return fuzzy recognition from hassil.""" + if lang_intents.fuzzy_matcher is None: + return None + + fuzzy_result = lang_intents.fuzzy_matcher.match(user_input.text) + if fuzzy_result is None: + return None + + response = "default" + if lang_intents.fuzzy_responses: + domain = "" # no domain + if "name" in fuzzy_result.slots: + domain = fuzzy_result.name_domain + elif "domain" in fuzzy_result.slots: + domain = fuzzy_result.slots["domain"].value + + slot_combo = tuple(sorted(fuzzy_result.slots)) + if ( + intent_responses := lang_intents.fuzzy_responses.get( + fuzzy_result.intent_name + ) + ) and (combo_responses := intent_responses.get(slot_combo)): + response = combo_responses.get(domain, response) + + entities = [ + MatchEntity(name=slot_name, value=slot_value.value, text=slot_value.text) + for slot_name, slot_value in fuzzy_result.slots.items() + ] + + return RecognizeResult( + intent=Intent(name=fuzzy_result.intent_name), + intent_data=IntentData(sentence_texts=[]), + intent_metadata={METADATA_FUZZY_MATCH: True}, + entities={entity.name: entity for entity in entities}, + entities_list=entities, + response=response, + ) + + def _recognize_unknown_names( + self, + lang_intents: LanguageIntents, + user_input: ConversationInput, + slot_lists: dict[str, SlotList], + intent_context: dict[str, Any] | None, + ) -> RecognizeResult | None: + """Return result with unknown names for an error message.""" + maybe_result: RecognizeResult | None = None + + best_num_matched_entities = 0 + best_num_unmatched_entities = 0 + best_num_unmatched_ranges = 0 + for result in recognize_all( + user_input.text, + lang_intents.intents, + slot_lists=slot_lists, + intent_context=intent_context, + allow_unmatched_entities=True, + ): + if result.text_chunks_matched < 1: + # Skip results that don't match any literal text + continue + + # Don't count missing entities that couldn't be filled from context + num_matched_entities = 0 + for matched_entity in result.entities_list: + if matched_entity.name not in result.unmatched_entities: + num_matched_entities += 1 + + num_unmatched_entities = 0 + num_unmatched_ranges = 0 + for unmatched_entity in result.unmatched_entities_list: + if isinstance(unmatched_entity, UnmatchedTextEntity): + if unmatched_entity.text != MISSING_ENTITY: + num_unmatched_entities += 1 + elif isinstance(unmatched_entity, UnmatchedRangeEntity): + num_unmatched_ranges += 1 + num_unmatched_entities += 1 + else: + num_unmatched_entities += 1 + + if ( + (maybe_result is None) # first result + or ( + # More literal text matched + result.text_chunks_matched > maybe_result.text_chunks_matched + ) + or ( + # More entities matched + num_matched_entities > best_num_matched_entities + ) + or ( + # Fewer unmatched entities + (num_matched_entities == best_num_matched_entities) + and (num_unmatched_entities < best_num_unmatched_entities) + ) + or ( + # Prefer unmatched ranges + (num_matched_entities == best_num_matched_entities) + and (num_unmatched_entities == best_num_unmatched_entities) + and (num_unmatched_ranges > best_num_unmatched_ranges) + ) + or ( + # Prefer match failures with entities + (result.text_chunks_matched == maybe_result.text_chunks_matched) + and (num_unmatched_entities == best_num_unmatched_entities) + and (num_unmatched_ranges == best_num_unmatched_ranges) + and ( + ("name" in result.entities) + or ("name" in result.unmatched_entities) + ) + ) + ): + maybe_result = result + best_num_matched_entities = num_matched_entities + best_num_unmatched_entities = num_unmatched_entities + best_num_unmatched_ranges = num_unmatched_ranges + + return maybe_result + def _get_unexposed_entity_names(self, text: str) -> TextSlotList: """Get filtered slot list with unexposed entity names in Home Assistant.""" if self._unexposed_names_trie is None: @@ -851,7 +962,7 @@ class DefaultAgent(ConversationEntity): if lang_intents is None: return - self._make_slot_lists() + await self._make_slot_lists() async def async_get_or_load_intents(self, language: str) -> LanguageIntents | None: """Load all intents of a language with lock.""" @@ -1002,12 +1113,85 @@ class DefaultAgent(ConversationEntity): intent_responses = responses_dict.get("intents", {}) error_responses = responses_dict.get("errors", {}) + if not self.fuzzy_matching: + _LOGGER.debug("Fuzzy matching is disabled") + return LanguageIntents( + intents, + intents_dict, + intent_responses, + error_responses, + language_variant, + ) + + # Load fuzzy + fuzzy_info = get_fuzzy_language(language_variant, json_load=json_load) + if fuzzy_info is None: + _LOGGER.debug( + "Fuzzy matching not available for language: %s", language_variant + ) + return LanguageIntents( + intents, + intents_dict, + intent_responses, + error_responses, + language_variant, + ) + + if self._fuzzy_config is None: + # Load shared config + self._fuzzy_config = get_fuzzy_config(json_load=json_load) + _LOGGER.debug("Loaded shared fuzzy matching config") + + assert self._fuzzy_config is not None + + fuzzy_matcher: FuzzyNgramMatcher | None = None + fuzzy_responses: FuzzyLanguageResponses | None = None + + start_time = time.monotonic() + fuzzy_responses = fuzzy_info.responses + fuzzy_matcher = FuzzyNgramMatcher( + intents=intents, + intent_models={ + intent_name: Sqlite3NgramModel( + order=fuzzy_model.order, + words={ + word: str(word_id) + for word, word_id in fuzzy_model.words.items() + }, + database_path=fuzzy_model.database_path, + ) + for intent_name, fuzzy_model in fuzzy_info.ngram_models.items() + }, + intent_slot_list_names=self._fuzzy_config.slot_list_names, + slot_combinations={ + intent_name: { + combo_key: [ + SlotCombinationInfo( + name_domains=(set(name_domains) if name_domains else None) + ) + ] + for combo_key, name_domains in intent_combos.items() + } + for intent_name, intent_combos in self._fuzzy_config.slot_combinations.items() + }, + domain_keywords=fuzzy_info.domain_keywords, + stop_words=fuzzy_info.stop_words, + ) + _LOGGER.debug( + "Loaded fuzzy matcher in %s second(s): language=%s, intents=%s", + time.monotonic() - start_time, + language_variant, + sorted(fuzzy_matcher.intent_models.keys()), + ) + return LanguageIntents( intents, intents_dict, intent_responses, error_responses, language_variant, + fuzzy_matcher=fuzzy_matcher, + fuzzy_responses=fuzzy_responses, ) @core.callback @@ -1027,8 +1211,7 @@ class DefaultAgent(ConversationEntity): # Slot lists have changed, so we must clear the cache self._intent_cache.clear() - @core.callback - def _make_slot_lists(self) -> dict[str, SlotList]: + async def _make_slot_lists(self) -> dict[str, SlotList]: """Create slot lists with areas and entity names/aliases.""" if self._slot_lists is not None: return self._slot_lists @@ -1089,6 +1272,10 @@ class DefaultAgent(ConversationEntity): "floor": TextSlotList.from_tuples(floor_names, allow_template=False), } + # Reload fuzzy matchers with new slot lists + if self.fuzzy_matching: + await self.hass.async_add_executor_job(self._load_fuzzy_matchers) + self._listen_clear_slot_list() _LOGGER.debug( @@ -1098,6 +1285,25 @@ class DefaultAgent(ConversationEntity): return self._slot_lists + def _load_fuzzy_matchers(self) -> None: + """Reload fuzzy matchers for all loaded languages.""" + for lang_intents in self._lang_intents.values(): + if (not isinstance(lang_intents, LanguageIntents)) or ( + lang_intents.fuzzy_matcher is None + ): + continue + + lang_matcher = lang_intents.fuzzy_matcher + lang_intents.fuzzy_matcher = FuzzyNgramMatcher( + intents=lang_matcher.intents, + intent_models=lang_matcher.intent_models, + intent_slot_list_names=lang_matcher.intent_slot_list_names, + slot_combinations=lang_matcher.slot_combinations, + domain_keywords=lang_matcher.domain_keywords, + stop_words=lang_matcher.stop_words, + slot_lists=self._slot_lists, + ) + def _make_intent_context( self, user_input: ConversationInput ) -> dict[str, Any] | None: @@ -1521,10 +1727,8 @@ def _get_match_error_response( def _collect_list_references(expression: Expression, list_names: set[str]) -> None: """Collect list reference names recursively.""" if isinstance(expression, Group): - grp: Group = expression - for item in grp.items: + for item in expression.items: _collect_list_references(item, list_names) elif isinstance(expression, ListReference): # {list} - list_ref: ListReference = expression - list_names.add(list_ref.slot_name) + list_names.add(expression.slot_name) diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index efcdcb8d69b..290e3aab955 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -26,7 +26,11 @@ from .agent_manager import ( get_agent_manager, ) from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY -from .default_agent import METADATA_CUSTOM_FILE, METADATA_CUSTOM_SENTENCE +from .default_agent import ( + METADATA_CUSTOM_FILE, + METADATA_CUSTOM_SENTENCE, + METADATA_FUZZY_MATCH, +) from .entity import ConversationEntity from .models import ConversationInput @@ -240,6 +244,8 @@ async def websocket_hass_agent_debug( "sentence_template": "", # When match is incomplete, this will contain the best slot guesses "unmatched_slots": _get_unmatched_slots(intent_result), + # True if match was not exact + "fuzzy_match": False, } if successful_match: @@ -251,16 +257,19 @@ async def websocket_hass_agent_debug( if intent_result.intent_sentence is not None: result_dict["sentence_template"] = intent_result.intent_sentence.text - # Inspect metadata to determine if this matched a custom sentence - if intent_result.intent_metadata and intent_result.intent_metadata.get( - METADATA_CUSTOM_SENTENCE - ): - result_dict["source"] = "custom" - result_dict["file"] = intent_result.intent_metadata.get( - METADATA_CUSTOM_FILE + if intent_result.intent_metadata: + # Inspect metadata to determine if this matched a custom sentence + if intent_result.intent_metadata.get(METADATA_CUSTOM_SENTENCE): + result_dict["source"] = "custom" + result_dict["file"] = intent_result.intent_metadata.get( + METADATA_CUSTOM_FILE + ) + else: + result_dict["source"] = "builtin" + + result_dict["fuzzy_match"] = intent_result.intent_metadata.get( + METADATA_FUZZY_MATCH, False ) - else: - result_dict["source"] = "builtin" result_dicts.append(result_dict) diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index e20452a1f93..681f6e7759d 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components import stt, tts, wake_word +from homeassistant.components import conversation, stt, tts, wake_word from homeassistant.components.assist_pipeline import DOMAIN, select as assist_select from homeassistant.components.assist_pipeline.const import ( BYTES_PER_CHUNK, @@ -295,6 +295,11 @@ async def init_supporting_components( assert await async_setup_component(hass, tts.DOMAIN, {"tts": {"platform": "test"}}) assert await async_setup_component(hass, stt.DOMAIN, {"stt": {"platform": "test"}}) assert await async_setup_component(hass, "media_source", {}) + assert await async_setup_component(hass, "conversation", {"conversation": {}}) + + # Disable fuzzy matching by default for tests + agent = hass.data[conversation.DATA_DEFAULT_ENTITY] + agent.fuzzy_matching = False config_entry = MockConfigEntry(domain="test") config_entry.add_to_hass(hass) diff --git a/tests/components/conversation/conftest.py b/tests/components/conversation/conftest.py index 8dfe879ee2b..19d8434fc5a 100644 --- a/tests/components/conversation/conftest.py +++ b/tests/components/conversation/conftest.py @@ -73,4 +73,8 @@ async def sl_setup(hass: HomeAssistant): async def init_components(hass: HomeAssistant): """Initialize relevant components with empty configs.""" assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "conversation", {}) + assert await async_setup_component(hass, "conversation", {conversation.DOMAIN: {}}) + + # Disable fuzzy matching by default for tests + agent = hass.data[conversation.DATA_DEFAULT_ENTITY] + agent.fuzzy_matching = False diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index 8f68274d37f..8b8ed6fa71c 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -464,6 +464,7 @@ 'value': 'my cool light', }), }), + 'fuzzy_match': False, 'intent': dict({ 'name': 'HassTurnOn', }), @@ -472,7 +473,6 @@ 'slots': dict({ 'name': 'my cool light', }), - 'source': 'builtin', 'targets': dict({ 'light.kitchen': dict({ 'matched': True, @@ -489,6 +489,7 @@ 'value': 'my cool light', }), }), + 'fuzzy_match': False, 'intent': dict({ 'name': 'HassTurnOff', }), @@ -497,7 +498,6 @@ 'slots': dict({ 'name': 'my cool light', }), - 'source': 'builtin', 'targets': dict({ 'light.kitchen': dict({ 'matched': True, @@ -519,6 +519,7 @@ 'value': 'light', }), }), + 'fuzzy_match': False, 'intent': dict({ 'name': 'HassTurnOn', }), @@ -528,7 +529,6 @@ 'area': 'kitchen', 'domain': 'light', }), - 'source': 'builtin', 'targets': dict({ 'light.kitchen': dict({ 'matched': True, @@ -555,6 +555,7 @@ 'value': 'on', }), }), + 'fuzzy_match': False, 'intent': dict({ 'name': 'HassGetState', }), @@ -565,7 +566,6 @@ 'domain': 'lights', 'state': 'on', }), - 'source': 'builtin', 'targets': dict({ 'light.kitchen': dict({ 'matched': False, @@ -590,6 +590,7 @@ }), }), 'file': 'en/beer.yaml', + 'fuzzy_match': False, 'intent': dict({ 'name': 'OrderBeer', }), @@ -630,6 +631,7 @@ 'value': 'test light', }), }), + 'fuzzy_match': False, 'intent': dict({ 'name': 'HassLightSet', }), @@ -639,7 +641,6 @@ 'brightness': '100', 'name': 'test light', }), - 'source': 'builtin', 'targets': dict({ 'light.demo_1234': dict({ 'matched': True, @@ -662,6 +663,7 @@ 'value': 'test light', }), }), + 'fuzzy_match': False, 'intent': dict({ 'name': 'HassLightSet', }), @@ -670,7 +672,6 @@ 'slots': dict({ 'name': 'test light', }), - 'source': 'builtin', 'targets': dict({ }), 'unmatched_slots': dict({ diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index f075f267111..7c5e897d86c 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -25,7 +25,12 @@ from homeassistant.components.intent import ( TimerInfo, async_register_timer_handler, ) -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.light import ( + ATTR_SUPPORTED_COLOR_MODES, + DOMAIN as LIGHT_DOMAIN, + ColorMode, + intent as light_intent, +) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, @@ -81,6 +86,10 @@ async def init_components(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "conversation", {}) assert await async_setup_component(hass, "intent", {}) + # Disable fuzzy matching by default for tests + agent = hass.data[DATA_DEFAULT_ENTITY] + agent.fuzzy_matching = False + @pytest.mark.parametrize( "er_kwargs", @@ -3287,3 +3296,97 @@ async def test_language_with_alternative_code( assert call.domain == LIGHT_DOMAIN assert call.service == "turn_on" assert call.data == {"entity_id": [entity_id]} + + +@pytest.mark.parametrize("fuzzy_matching", [True, False]) +@pytest.mark.parametrize( + ("sentence", "intent_type", "slots"), + [ + ("time", "HassGetCurrentTime", {}), + ("how about my timers", "HassTimerStatus", {}), + ( + "the office needs more blue", + "HassLightSet", + {"area": "office", "color": "blue"}, + ), + ( + "50% office light", + "HassLightSet", + {"name": "office light", "brightness": "50%"}, + ), + ], +) +async def test_fuzzy_matching( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + fuzzy_matching: bool, + sentence: str, + intent_type: str, + slots: dict[str, Any], +) -> None: + """Test fuzzy vs. non-fuzzy matching on some English sentences.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "conversation", {}) + assert await async_setup_component(hass, "intent", {}) + await light_intent.async_setup_intents(hass) + + agent = hass.data[DATA_DEFAULT_ENTITY] + agent.fuzzy_matching = fuzzy_matching + + area_office = area_registry.async_get_or_create("office_id") + area_office = area_registry.async_update(area_office.id, name="office") + + entry = MockConfigEntry() + entry.add_to_hass(hass) + office_satellite = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("demo", "id-1234")}, + ) + device_registry.async_update_device(office_satellite.id, area_id=area_office.id) + + office_light = entity_registry.async_get_or_create("light", "demo", "1234") + office_light = entity_registry.async_update_entity( + office_light.entity_id, area_id=area_office.id + ) + hass.states.async_set( + office_light.entity_id, + "on", + attributes={ + ATTR_FRIENDLY_NAME: "office light", + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.BRIGHTNESS, ColorMode.RGB], + }, + ) + _on_calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + + result = await conversation.async_converse( + hass, + sentence, + None, + Context(), + language="en", + device_id=office_satellite.id, + ) + response = result.response + + if not fuzzy_matching: + # Should not match + assert response.response_type == intent.IntentResponseType.ERROR + return + + assert response.response_type in ( + intent.IntentResponseType.ACTION_DONE, + intent.IntentResponseType.QUERY_ANSWER, + ) + assert response.intent is not None + assert response.intent.intent_type == intent_type + + # Verify slot texts match + actual_slots = { + slot_name: slot_value["text"] + for slot_name, slot_value in response.intent.slots.items() + if slot_name != "preferred_area_id" # context area + } + assert actual_slots == slots From 9f36b2dcde37027cccf55d91f11f2b6851a186da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 19:31:10 -0500 Subject: [PATCH 1025/1113] Bump protobuf to 6.32.0 (#150667) --- homeassistant/package_constraints.txt | 2 +- script/gen_requirements_all.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5fa00656e5a..f9d9e1f3d4b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -144,7 +144,7 @@ iso4217!=1.10.20220401 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==6.31.1 +protobuf==6.32.0 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index b710fbc31ed..2546c871707 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -170,7 +170,7 @@ iso4217!=1.10.20220401 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==6.31.1 +protobuf==6.32.0 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder From a785f3d509f6ced0e2c0d55bc88b6028ebf7a03b Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 15 Aug 2025 05:45:42 +0200 Subject: [PATCH 1026/1113] Increase test coverage of Habitica (#150671) --- .../components/habitica/fixtures/party_2.json | 74 ++++++++++++++++ tests/components/habitica/test_image.py | 87 ++++++++++++++++++- 2 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 tests/components/habitica/fixtures/party_2.json diff --git a/tests/components/habitica/fixtures/party_2.json b/tests/components/habitica/fixtures/party_2.json new file mode 100644 index 00000000000..2c0ff528e32 --- /dev/null +++ b/tests/components/habitica/fixtures/party_2.json @@ -0,0 +1,74 @@ +{ + "success": true, + "data": { + "leaderOnly": { + "challenges": false, + "getGems": false + }, + "quest": { + "progress": { + "collect": {}, + "hp": 100 + }, + "key": "dustbunnies", + "active": true, + "leader": "d69833ef-4542-4259-ba50-9b4a1a841bcf", + "members": { + "d69833ef-4542-4259-ba50-9b4a1a841bcf": true + }, + "extra": {} + }, + "tasksOrder": { + "habits": [], + "dailys": [], + "todos": [], + "rewards": [] + }, + "purchased": { + "plan": { + "consecutive": { + "count": 0, + "offset": 0, + "gemCapExtra": 0, + "trinkets": 0 + }, + "quantity": 1, + "extraMonths": 0, + "gemsBought": 0, + "cumulativeCount": 0, + "mysteryItems": [] + } + }, + "cron": {}, + "_id": "1e87097c-4c03-4f8c-a475-67cc7da7f409", + "name": "test-user's Party", + "type": "party", + "privacy": "private", + "chat": [], + "memberCount": 2, + "challengeCount": 0, + "balance": 0, + "managers": {}, + "categories": [], + "leader": { + "auth": { + "local": { + "username": "test-username" + } + }, + "flags": { + "verifiedUsername": true + }, + "profile": { + "name": "test-user" + }, + "_id": "af36e2a8-7927-4dec-a258-400ade7f0ae3", + "id": "af36e2a8-7927-4dec-a258-400ade7f0ae3" + }, + "summary": "test-user's Party", + "id": "1e87097c-4c03-4f8c-a475-67cc7da7f409" + }, + "notifications": [], + "userV": 0, + "appVersion": "5.38.0" +} diff --git a/tests/components/habitica/test_image.py b/tests/components/habitica/test_image.py index 42a87d21a8a..b0810d8e76f 100644 --- a/tests/components/habitica/test_image.py +++ b/tests/components/habitica/test_image.py @@ -8,12 +8,13 @@ import sys from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from habiticalib import HabiticaUserResponse +from habiticalib import HabiticaGroupsResponse, HabiticaUserResponse import pytest +import respx from syrupy.assertion import SnapshotAssertion from syrupy.extensions.image import PNGImageSnapshotExtension -from homeassistant.components.habitica.const import DOMAIN +from homeassistant.components.habitica.const import ASSETS_URL, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -97,3 +98,85 @@ async def test_image_platform( assert (await resp.read()) == snapshot( extension_class=PNGImageSnapshotExtension ) + + +@pytest.mark.usefixtures("habitica") +@respx.mock +async def test_load_image_from_url( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + hass_client: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test loading of image from URL.""" + freezer.move_to("2024-09-20T22:00:00.000") + + call1 = respx.get(f"{ASSETS_URL}quest_atom1.png").respond(content=b"\x89PNG") + call2 = respx.get(f"{ASSETS_URL}quest_dustbunnies.png").respond(content=b"\x89PNG") + + 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.test_user_s_party_quest")) + assert state.state == "2024-09-20T22:00:00+00:00" + + client = await hass_client() + resp = await client.get(state.attributes["entity_picture"]) + + assert resp.status == HTTPStatus.OK + + assert (await resp.read()) == b"\x89PNG" + + assert call1.call_count == 1 + + habitica.get_group.return_value = HabiticaGroupsResponse.from_json( + await async_load_fixture(hass, "party_2.json", DOMAIN) + ) + freezer.tick(timedelta(minutes=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("image.test_user_s_party_quest")) + assert state.state == "2024-09-20T22:15:00+00:00" + + client = await hass_client() + resp = await client.get(state.attributes["entity_picture"]) + + assert resp.status == HTTPStatus.OK + + assert (await resp.read()) == b"\x89PNG" + assert call2.call_count == 1 + + +@pytest.mark.usefixtures("habitica") +@respx.mock +async def test_load_image_not_found( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test NotFound error.""" + freezer.move_to("2024-09-20T22:00:00.000") + + call1 = respx.get(f"{ASSETS_URL}quest_atom1.png").respond(status_code=404) + + 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.test_user_s_party_quest")) + assert state.state == "2024-09-20T22:00:00+00:00" + + client = await hass_client() + resp = await client.get(state.attributes["entity_picture"]) + + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + + assert call1.call_count == 1 From 25f7c0249896cd98d3e88ee953dd10f977be4b2f Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Thu, 14 Aug 2025 23:46:59 -0400 Subject: [PATCH 1027/1113] Bump python-snoo to 0.8.3 (#150670) --- 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 0a2301c6fd8..5a162a9e9d3 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.8.2"] + "requirements": ["python-snoo==0.8.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2ce816f94c8..5072eff94c2 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.8.2 +python-snoo==0.8.3 # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bdc736ec63a..b974f1faa4e 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.8.2 +python-snoo==0.8.3 # homeassistant.components.songpal python-songpal==0.16.2 From 3e9e9b04894d4c2b86318c4b6f32acee1ca90db4 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Thu, 14 Aug 2025 20:47:55 -0700 Subject: [PATCH 1028/1113] Fix demo media_player.browse browsing (#150669) --- homeassistant/components/demo/media_player.py | 10 ++++++ tests/components/demo/test_media_player.py | 31 ++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index ad7ddcba285..0c001921c7a 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import datetime from typing import Any +from homeassistant.components import media_source from homeassistant.components.media_player import ( BrowseMedia, MediaClass, @@ -396,6 +397,15 @@ class DemoBrowsePlayer(AbstractDemoPlayer): _attr_supported_features = BROWSE_PLAYER_SUPPORT + async def async_browse_media( + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """Implement the websocket media browsing helper.""" + + return await media_source.async_browse_media(self.hass, media_content_id) + class DemoGroupPlayer(AbstractDemoPlayer): """A Demo media player that supports grouping.""" diff --git a/tests/components/demo/test_media_player.py b/tests/components/demo/test_media_player.py index 7487a4c13e3..c22b28ae799 100644 --- a/tests/components/demo/test_media_player.py +++ b/tests/components/demo/test_media_player.py @@ -55,7 +55,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import DATA_CLIENTSESSION, _make_key from homeassistant.setup import async_setup_component -from tests.typing import ClientSessionGenerator +from tests.typing import ClientSessionGenerator, WebSocketGenerator TEST_ENTITY_ID = "media_player.walkman" @@ -563,3 +563,32 @@ async def test_grouping(hass: HomeAssistant) -> None: ) state = hass.states.get(walkman) assert state.attributes.get(ATTR_GROUP_MEMBERS) == [] + + +async def test_browse( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the media player browse.""" + entity = "media_player.browse" + + await async_setup_component(hass, "media_source", {"media_source": {}}) + assert await async_setup_component( + hass, MP_DOMAIN, {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": entity, + } + ) + + msg = await websocket_client.receive_json() + assert msg["success"] + assert msg["result"]["title"] == "media" + assert msg["result"]["media_class"] == "directory" + assert len(msg["result"]["children"]) From 00b765893dd4e0c822d789405e9fe71174b82d09 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 22:49:31 -0500 Subject: [PATCH 1029/1113] Bump onvif-zeep-async to 4.0.3 (#150663) --- 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 fbb1454ec2a..787040d5691 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.2", "WSDiscovery==2.1.2"] + "requirements": ["onvif-zeep-async==4.0.3", "WSDiscovery==2.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5072eff94c2..dcd5fac6143 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.2 +onvif-zeep-async==4.0.3 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b974f1faa4e..5529e2d848e 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.2 +onvif-zeep-async==4.0.3 # homeassistant.components.opengarage open-garage==0.2.0 From 2a6d1180f44fd9ddd0eba49e8445e78ae619fdec Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 15 Aug 2025 08:13:22 +0200 Subject: [PATCH 1030/1113] Update py-madvr2 to 1.6.40 (#150647) --- homeassistant/components/madvr/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/madvr/manifest.json b/homeassistant/components/madvr/manifest.json index 0ac906fdbef..e45a4c60f30 100644 --- a/homeassistant/components/madvr/manifest.json +++ b/homeassistant/components/madvr/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/madvr", "integration_type": "device", "iot_class": "local_push", - "requirements": ["py-madvr2==1.6.32"] + "requirements": ["py-madvr2==1.6.40"] } diff --git a/requirements_all.txt b/requirements_all.txt index dcd5fac6143..a86f5c0b32c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1764,7 +1764,7 @@ py-dormakaba-dkey==1.0.6 py-improv-ble-client==1.0.3 # homeassistant.components.madvr -py-madvr2==1.6.32 +py-madvr2==1.6.40 # homeassistant.components.melissa py-melissa-climate==2.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5529e2d848e..08571928eac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1490,7 +1490,7 @@ py-dormakaba-dkey==1.0.6 py-improv-ble-client==1.0.3 # homeassistant.components.madvr -py-madvr2==1.6.32 +py-madvr2==1.6.40 # homeassistant.components.melissa py-melissa-climate==2.1.4 From 58f8b3c4014cd72743972600ba5d1e9729262092 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Fri, 15 Aug 2025 11:29:49 +0200 Subject: [PATCH 1031/1113] Bump Python Matter server to 8.1.0 (#150631) --- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 9db0dfc9881..b79113d422e 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -7,6 +7,6 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==8.0.0"], + "requirements": ["python-matter-server==8.1.0"], "zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index a86f5c0b32c..ac41bfa82d8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2460,7 +2460,7 @@ python-linkplay==0.2.12 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==8.0.0 +python-matter-server==8.1.0 # homeassistant.components.melcloud python-melcloud==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 08571928eac..041555bdb6b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2033,7 +2033,7 @@ python-linkplay==0.2.12 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==8.0.0 +python-matter-server==8.1.0 # homeassistant.components.melcloud python-melcloud==0.1.0 From 83ee380b17f18837c8eedacd902eb2efd4df66ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 15 Aug 2025 11:35:52 +0200 Subject: [PATCH 1032/1113] Bump hass-nabucasa from 0.111.2 to 1.0.0 and refactor related code (#150566) --- .../components/cloud/google_config.py | 4 +-- 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 +- tests/components/cloud/conftest.py | 5 ++- tests/components/cloud/test_google_config.py | 18 +++++------ tests/components/cloud/test_http_api.py | 31 ++++++++++--------- 10 files changed, 37 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 2b6f45ec474..62496906c9d 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -7,7 +7,7 @@ from http import HTTPStatus import logging from typing import TYPE_CHECKING, Any -from hass_nabucasa import Cloud, cloud_api +from hass_nabucasa import Cloud from hass_nabucasa.google_report_state import ErrorResponse from homeassistant.components.binary_sensor import BinarySensorDeviceClass @@ -377,7 +377,7 @@ class CloudGoogleConfig(AbstractConfig): return HTTPStatus.OK async with self._sync_entities_lock: - resp = await cloud_api.async_google_actions_request_sync(self._cloud) + resp = await self._cloud.google_report_state.request_sync() return resp.status async def async_connect_agent_user(self, agent_user_id: str) -> None: diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index cb3537a59e5..a0f88b3a558 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.2"], + "requirements": ["hass-nabucasa==1.0.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f9d9e1f3d4b..cfec93ae4b0 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==5.0.1 -hass-nabucasa==0.111.2 +hass-nabucasa==1.0.0 hassil==3.1.0 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250811.0 diff --git a/pyproject.toml b/pyproject.toml index 1f74056ac91..3e24035f271 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.2", + "hass-nabucasa==1.0.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 502e1e225cf..f053bc0d541 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.2 +hass-nabucasa==1.0.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 ac41bfa82d8..6112d8d49b5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1133,7 +1133,7 @@ habiticalib==0.4.2 habluetooth==5.0.1 # homeassistant.components.cloud -hass-nabucasa==0.111.2 +hass-nabucasa==1.0.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 041555bdb6b..b1dce41c9c4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -994,7 +994,7 @@ habiticalib==0.4.2 habluetooth==5.0.1 # homeassistant.components.cloud -hass-nabucasa==0.111.2 +hass-nabucasa==1.0.0 # homeassistant.components.assist_satellite # homeassistant.components.conversation diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index a4625fcce92..10d38c227f1 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -55,7 +55,10 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock]: # Attributes set in the constructor without parameters. # We spec the mocks with the real classes # and set constructor attributes or mock properties as needed. - mock_cloud.google_report_state = MagicMock(spec=GoogleReportState) + mock_cloud.google_report_state = MagicMock( + spec=GoogleReportState, + request_sync=AsyncMock(), + ) mock_cloud.cloudhooks = MagicMock(spec=Cloudhooks) mock_cloud.remote = MagicMock( spec=RemoteUI, diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index cb456be5036..e6fb289d09e 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -1,7 +1,7 @@ """Test the Cloud Google Config.""" from http import HTTPStatus -from unittest.mock import Mock, PropertyMock, patch +from unittest.mock import AsyncMock, Mock, PropertyMock, patch from freezegun import freeze_time import pytest @@ -119,15 +119,13 @@ async def test_sync_entities( assert len(mock_conf.async_get_agent_users()) == 1 - with patch( - "hass_nabucasa.cloud_api.async_google_actions_request_sync", - return_value=Mock(status=HTTPStatus.NOT_FOUND), - ) as mock_request_sync: - assert ( - await mock_conf.async_sync_entities("mock-user-id") == HTTPStatus.NOT_FOUND - ) - assert len(mock_conf.async_get_agent_users()) == 0 - assert len(mock_request_sync.mock_calls) == 1 + mock_conf._cloud.google_report_state.request_sync = AsyncMock( + return_value=Mock(status=HTTPStatus.NOT_FOUND) + ) + + assert await mock_conf.async_sync_entities("mock-user-id") == HTTPStatus.NOT_FOUND + assert len(mock_conf.async_get_agent_users()) == 0 + assert len(mock_conf._cloud.google_report_state.request_sync.mock_calls) == 1 async def test_google_update_expose_trigger_sync( diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index f125a5cbdae..96927477b0a 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -139,31 +139,34 @@ async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: async def test_google_actions_sync( setup_cloud: None, hass_client: ClientSessionGenerator, + cloud: MagicMock, ) -> None: """Test syncing Google Actions.""" cloud_client = await hass_client() - with patch( - "hass_nabucasa.cloud_api.async_google_actions_request_sync", - return_value=Mock(status=200), - ) as mock_request_sync: - req = await cloud_client.post("/api/cloud/google_actions/sync") - assert req.status == HTTPStatus.OK - assert mock_request_sync.call_count == 1 + + cloud.google_report_state.request_sync = AsyncMock( + return_value=Mock(status=HTTPStatus.OK) + ) + + req = await cloud_client.post("/api/cloud/google_actions/sync") + assert req.status == HTTPStatus.OK + assert len(cloud.google_report_state.request_sync.mock_calls) == 1 async def test_google_actions_sync_fails( setup_cloud: None, hass_client: ClientSessionGenerator, + cloud: MagicMock, ) -> None: """Test syncing Google Actions gone bad.""" cloud_client = await hass_client() - with patch( - "hass_nabucasa.cloud_api.async_google_actions_request_sync", - return_value=Mock(status=HTTPStatus.INTERNAL_SERVER_ERROR), - ) as mock_request_sync: - req = await cloud_client.post("/api/cloud/google_actions/sync") - assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR - assert mock_request_sync.call_count == 1 + cloud.google_report_state.request_sync = AsyncMock( + return_value=Mock(status=HTTPStatus.INTERNAL_SERVER_ERROR) + ) + + req = await cloud_client.post("/api/cloud/google_actions/sync") + assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR + assert len(cloud.google_report_state.request_sync.mock_calls) == 1 @pytest.mark.parametrize( From 7bd126dc8ea0bba4fa77a50128017aae1b246b3e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 15 Aug 2025 13:04:12 +0200 Subject: [PATCH 1033/1113] Assert the MQTT config entry is reloaded on subentry creation and mutation (#150636) --- tests/components/mqtt/test_config_flow.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index ff1f954bace..3b4f090aef3 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -3367,6 +3367,7 @@ async def test_migrate_of_incompatible_config_entry( async def test_subentry_configflow( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, + mock_reload_after_entry_update: MagicMock, config_subentries_data: dict[str, Any], mock_device_user_input: dict[str, Any], mock_entity_user_input: dict[str, Any], @@ -3501,6 +3502,10 @@ async def test_subentry_configflow( assert subentry_device_data[option] == value await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + + # Assert the entry is reloaded to set up the entity + assert len(mock_reload_after_entry_update.mock_calls) == 1 @pytest.mark.parametrize( @@ -3641,6 +3646,7 @@ async def test_subentry_reconfigure_remove_entity( async def test_subentry_reconfigure_edit_entity_multi_entitites( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, + mock_reload_after_entry_update: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, user_input_mqtt: dict[str, Any], @@ -3758,6 +3764,10 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( for key, value in user_input_mqtt.items(): assert new_components[object_list[1]][key] == value + # Assert the entry is reloaded to set up the entity + await hass.async_block_till_done(wait_background_tasks=True) + assert len(mock_reload_after_entry_update.mock_calls) == 1 + @pytest.mark.parametrize( ( From 792bb5781d0d6186091c740cd722432ac3589835 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Fri, 15 Aug 2025 07:53:48 -0400 Subject: [PATCH 1034/1113] Fix optimistic set to false for template entities (#150421) --- homeassistant/components/template/entity.py | 10 ++-- .../components/template/template_entity.py | 2 +- .../template/test_alarm_control_panel.py | 32 +++++++++++++ tests/components/template/test_cover.py | 29 ++++++++++- tests/components/template/test_fan.py | 33 +++++++++++++ tests/components/template/test_light.py | 36 ++++++++++++++ tests/components/template/test_lock.py | 33 +++++++++++++ tests/components/template/test_number.py | 31 ++++++++++++ tests/components/template/test_select.py | 36 ++++++++++++++ tests/components/template/test_switch.py | 37 ++++++++++++++ tests/components/template/test_vacuum.py | 48 +++++++++++++++++++ 11 files changed, 322 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index 03a93f50ec3..4901a7a7be8 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -34,16 +34,20 @@ class AbstractTemplateEntity(Entity): self._action_scripts: dict[str, Script] = {} if self._optimistic_entity: + optimistic = config.get(CONF_OPTIMISTIC) + self._template = config.get(CONF_STATE) - optimistic = self._template is None + assumed_optimistic = self._template is None if self._extra_optimistic_options: - optimistic = optimistic and all( + assumed_optimistic = assumed_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) + self._attr_assumed_state = optimistic or ( + optimistic is None and assumed_optimistic + ) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 1bc49bceafd..3ba89cae1f4 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -102,7 +102,7 @@ TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA = vol.Schema( TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA = { - vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, + vol.Optional(CONF_OPTIMISTIC): cv.boolean, } diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index c1df654e328..319d02a1056 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -973,3 +973,35 @@ async def test_optimistic(hass: HomeAssistant) -> None: state = hass.states.get(TEST_ENTITY_ID) assert state.state == AlarmControlPanelState.ARMED_HOME + + +@pytest.mark.parametrize( + ("count", "panel_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ states('alarm_control_panel.test') }}", + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + "optimistic": False, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_panel") +async def test_not_optimistic(hass: HomeAssistant) -> None: + """Test optimistic yaml option set to false.""" + 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 == STATE_UNKNOWN diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index 692567c7aa8..2a83967b048 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -628,11 +628,38 @@ async def test_template_position( ], ) @pytest.mark.usefixtures("setup_cover") -async def test_template_not_optimistic(hass: HomeAssistant) -> None: +async def test_template_not_optimistic( + hass: HomeAssistant, + calls: list[ServiceCall], +) -> None: """Test the is_closed attribute.""" state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_UNKNOWN + # Test to make sure optimistic is not set with only a position template. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + # Test to make sure optimistic is not set with only a position template. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + @pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 == 1 }}")]) @pytest.mark.parametrize( diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index b9161edf61a..81486d75137 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -1885,6 +1885,39 @@ async def test_optimistic_option(hass: HomeAssistant) -> None: assert state.state == STATE_OFF +@pytest.mark.parametrize( + ("count", "fan_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ is_state('sensor.test_sensor', 'on') }}", + "turn_on": [], + "turn_off": [], + "optimistic": False, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_fan") +async def test_not_optimistic(hass: HomeAssistant) -> None: + """Test optimistic yaml option set to false.""" + 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_OFF + + async def test_setup_config_entry( hass: HomeAssistant, snapshot: SnapshotAssertion, diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index 0549f9981e7..e5d05cfa08f 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -2795,6 +2795,42 @@ async def test_optimistic_option(hass: HomeAssistant) -> None: assert state.state == STATE_OFF +@pytest.mark.parametrize( + ("count", "light_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ is_state('light.test_state', 'on') }}", + "turn_on": [], + "turn_off": [], + "optimistic": False, + }, + ) + ], +) +@pytest.mark.parametrize( + ("style", "expected"), + [ + (ConfigurationStyle.MODERN, STATE_OFF), + (ConfigurationStyle.TRIGGER, STATE_UNKNOWN), + ], +) +@pytest.mark.usefixtures("setup_light") +async def test_not_optimistic(hass: HomeAssistant, expected: str) -> None: + """Test optimistic yaml option set to false.""" + 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 == expected + + async def test_setup_config_entry( hass: HomeAssistant, snapshot: SnapshotAssertion, diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index 823306015bf..6a4164fb802 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -1190,6 +1190,39 @@ async def test_optimistic(hass: HomeAssistant) -> None: assert state.state == LockState.UNLOCKED +@pytest.mark.parametrize( + ("count", "lock_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ is_state('sensor.test_state', 'on') }}", + "lock": [], + "unlock": [], + "optimistic": False, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_lock") +async def test_not_optimistic(hass: HomeAssistant) -> None: + """Test optimistic yaml option set to false.""" + 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.UNLOCKED + + async def test_setup_config_entry( hass: HomeAssistant, snapshot: SnapshotAssertion, diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py index 0ae98a23ae4..f10664e0d5f 100644 --- a/tests/components/template/test_number.py +++ b/tests/components/template/test_number.py @@ -605,6 +605,37 @@ async def test_optimistic(hass: HomeAssistant) -> None: assert float(state.state) == 2 +@pytest.mark.parametrize( + ("count", "number_config"), + [ + ( + 1, + { + "state": "{{ states('sensor.test_state') }}", + "optimistic": False, + "set_value": [], + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_number") +async def test_not_optimistic(hass: HomeAssistant) -> None: + """Test optimistic yaml option set to false.""" + 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 state.state == STATE_UNKNOWN + + @pytest.mark.parametrize( ("count", "number_config"), [ diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index f613fa865a6..eda27f18100 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -601,6 +601,42 @@ async def test_optimistic(hass: HomeAssistant) -> None: assert state.state == "yes" +@pytest.mark.parametrize( + ("count", "select_config"), + [ + ( + 1, + { + "state": "{{ states('select.test_state') }}", + "optimistic": False, + "options": "{{ ['test', 'yes', 'no'] }}", + "select_option": [], + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_select") +async def test_not_optimistic(hass: HomeAssistant) -> None: + """Test optimistic yaml option set to false.""" + # Ensure Trigger template entities update the options list + 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 == STATE_UNKNOWN + + @pytest.mark.parametrize( ("count", "select_config"), [ diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index a32f1df4c76..5a884160fe8 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -14,6 +14,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import CoreState, HomeAssistant, ServiceCall, State from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -1267,3 +1268,39 @@ async def test_optimistic_option(hass: HomeAssistant) -> 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": False, + }, + ) + ], +) +@pytest.mark.parametrize( + ("style", "expected"), + [ + (ConfigurationStyle.MODERN, STATE_OFF), + (ConfigurationStyle.TRIGGER, STATE_UNKNOWN), + ], +) +@pytest.mark.usefixtures("setup_switch") +async def test_not_optimistic(hass: HomeAssistant, expected: str) -> None: + """Test optimistic yaml option set to false.""" + 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 == expected diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index 8c2773956b2..21592718551 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -1299,6 +1299,54 @@ async def test_optimistic_option( assert state.state == VacuumActivity.DOCKED +@pytest.mark.parametrize( + ("count", "vacuum_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ states('sensor.test_state') }}", + "start": [], + **TEMPLATE_VACUUM_ACTIONS, + "optimistic": False, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + "service", + [ + vacuum.SERVICE_START, + vacuum.SERVICE_PAUSE, + vacuum.SERVICE_STOP, + vacuum.SERVICE_RETURN_TO_BASE, + vacuum.SERVICE_CLEAN_SPOT, + ], +) +@pytest.mark.usefixtures("setup_vacuum") +async def test_not_optimistic( + hass: HomeAssistant, + service: str, + calls: list[ServiceCall], +) -> None: + """Test optimistic yaml option set to false.""" + 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 == STATE_UNKNOWN + + async def test_setup_config_entry( hass: HomeAssistant, snapshot: SnapshotAssertion, From 64768b10366275c053077760b474361cf2097dd8 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Fri, 15 Aug 2025 13:58:03 +0200 Subject: [PATCH 1035/1113] Fix re-auth flow for Volvo integration (#150478) --- homeassistant/components/volvo/config_flow.py | 6 +-- tests/components/volvo/test_config_flow.py | 49 ++++++++++++++++++- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/volvo/config_flow.py b/homeassistant/components/volvo/config_flow.py index f187d751a2d..0ae0e54077e 100644 --- a/homeassistant/components/volvo/config_flow.py +++ b/homeassistant/components/volvo/config_flow.py @@ -69,7 +69,7 @@ class VolvoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: """Create an entry for the flow.""" - self._config_data |= data + self._config_data |= (self.init_data or {}) | data return await self.async_step_api_key() async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult: @@ -77,7 +77,7 @@ class VolvoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): return await self.async_step_reauth_confirm() async def async_step_reconfigure( - self, _: dict[str, Any] | None = None + self, data: dict[str, Any] | None = None ) -> ConfigFlowResult: """Reconfigure the entry.""" return await self.async_step_api_key() @@ -121,7 +121,7 @@ class VolvoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): if user_input is None: if self.source == SOURCE_REAUTH: - user_input = self._config_data = dict(self._get_reauth_entry().data) + user_input = self._config_data api = _create_volvo_cars_api( self.hass, self._config_data[CONF_TOKEN][CONF_ACCESS_TOKEN], diff --git a/tests/components/volvo/test_config_flow.py b/tests/components/volvo/test_config_flow.py index 91a7803dce5..3129b1383fe 100644 --- a/tests/components/volvo/test_config_flow.py +++ b/tests/components/volvo/test_config_flow.py @@ -13,7 +13,7 @@ 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.const import CONF_ACCESS_TOKEN, CONF_API_KEY, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow @@ -117,6 +117,53 @@ async def test_reauth_flow( assert result["reason"] == "reauth_successful" +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_no_stale_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + mock_config_flow_api: VolvoCarsApi, +) -> None: + """Test if reauthentication flow does not use stale data.""" + old_access_token = mock_config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] + + with patch( + "homeassistant.components.volvo.config_flow._create_volvo_cars_api", + return_value=mock_config_flow_api, + ) as mock_create_volvo_cars_api: + 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, + ) + + assert mock_create_volvo_cars_api.called + call = mock_create_volvo_cars_api.call_args_list[0] + access_token_arg = call.args[1] + assert old_access_token != access_token_arg + + async def test_reconfigure_flow( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From a742125f13ea1e2d60f8d705e0c94cee1baeab58 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 13:58:23 +0200 Subject: [PATCH 1036/1113] Add serial number to Emonitor device (#150692) --- homeassistant/components/emonitor/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/emonitor/sensor.py b/homeassistant/components/emonitor/sensor.py index be9e2ecb4cc..3e2f6dcbc8f 100644 --- a/homeassistant/components/emonitor/sensor.py +++ b/homeassistant/components/emonitor/sensor.py @@ -93,6 +93,7 @@ class EmonitorPowerSensor(CoordinatorEntity[EmonitorStatus], SensorEntity): manufacturer="Powerhouse Dynamics, Inc.", name=device_name, sw_version=emonitor_status.hardware.firmware_version, + serial_number=emonitor_status.hardware.serial_number, ) self._attr_extra_state_attributes = {"channel": channel_number} self._attr_native_value = self._paired_attr(self.entity_description.key) From b300654e15a656a9816f0dbbac8efc92257ae75b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 13:58:44 +0200 Subject: [PATCH 1037/1113] Add serial number to Dremel device (#150691) --- homeassistant/components/dremel_3d_printer/entity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/dremel_3d_printer/entity.py b/homeassistant/components/dremel_3d_printer/entity.py index 46686e47e1f..c2823e594a8 100644 --- a/homeassistant/components/dremel_3d_printer/entity.py +++ b/homeassistant/components/dremel_3d_printer/entity.py @@ -30,6 +30,7 @@ class Dremel3DPrinterEntity(CoordinatorEntity[Dremel3DPrinterDataUpdateCoordinat """Return device information about this Dremel printer.""" return DeviceInfo( identifiers={(DOMAIN, self._api.get_serial_number())}, + serial_number=self._api.get_serial_number(), manufacturer=self._api.get_manufacturer(), model=self._api.get_model(), name=self._api.get_title(), From facf217b995982ff5c504bd59d419a59c31ee068 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 15 Aug 2025 13:59:35 +0200 Subject: [PATCH 1038/1113] Fix missing labels for subdiv in workday (#150684) --- homeassistant/components/workday/config_flow.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 1d91e1d5ae3..20d9040e527 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -86,6 +86,9 @@ def add_province_and_language_to_schema( SelectOptionDict(value=k, label=", ".join(v)) for k, v in subdiv_aliases.items() ] + for option in province_options: + if option["label"] == "": + option["label"] = option["value"] else: province_options = provinces province_schema = { From 602497904be5244307438c10c80209f0ca67a6fa Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:01:42 +0200 Subject: [PATCH 1039/1113] Set firmware version to the right field in Guardian (#150697) --- homeassistant/components/guardian/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/guardian/entity.py b/homeassistant/components/guardian/entity.py index c48c87afa01..760b9423afd 100644 --- a/homeassistant/components/guardian/entity.py +++ b/homeassistant/components/guardian/entity.py @@ -74,7 +74,7 @@ class ValveControllerEntity(GuardianEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, entry.data[CONF_UID])}, manufacturer="Elexa", - model=self._diagnostics_coordinator.data["firmware"], + sw_version=self._diagnostics_coordinator.data["firmware"], name=f"Guardian valve controller {entry.data[CONF_UID]}", ) self._attr_unique_id = f"{entry.data[CONF_UID]}_{description.key}" From bc89e8fd3c6bdd003bf2915e142287f92cf7e1f1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:03:30 +0200 Subject: [PATCH 1040/1113] Move Notion hardware revision to hw_version (#150701) --- homeassistant/components/notion/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/notion/entity.py b/homeassistant/components/notion/entity.py index 11e470f1d26..387eaf2e423 100644 --- a/homeassistant/components/notion/entity.py +++ b/homeassistant/components/notion/entity.py @@ -45,9 +45,9 @@ class NotionEntity(CoordinatorEntity[NotionDataUpdateCoordinator]): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, sensor.hardware_id)}, manufacturer="Silicon Labs", - model=str(sensor.hardware_revision), name=str(sensor.name).capitalize(), sw_version=sensor.firmware_version, + hw_version=str(sensor.hardware_revision), ) if bridge := self._async_get_bridge(bridge_id): From 8da75490c098b36a76eff45695a1cf156c0563fc Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:04:59 +0200 Subject: [PATCH 1041/1113] Add hw_version to RainMachine device (#150705) --- homeassistant/components/rainmachine/entity.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/rainmachine/entity.py b/homeassistant/components/rainmachine/entity.py index 1289d3e808e..441cf8237b6 100644 --- a/homeassistant/components/rainmachine/entity.py +++ b/homeassistant/components/rainmachine/entity.py @@ -56,11 +56,9 @@ class RainMachineEntity(CoordinatorEntity[RainMachineDataUpdateCoordinator]): connections={(dr.CONNECTION_NETWORK_MAC, self._data.controller.mac)}, name=self._data.controller.name.capitalize(), manufacturer="RainMachine", - model=( - f"Version {self._version_coordinator.data['hwVer']} " - f"(API: {self._version_coordinator.data['apiVer']})" - ), - sw_version=self._version_coordinator.data["swVer"], + hw_version=self._version_coordinator.data["hwVer"], + sw_version=f"{self._version_coordinator.data['swVer']} " + f"(API: {self._version_coordinator.data['apiVer']})", ) @callback From ebbeef80218773655c531aff76eb52e3c7f539d4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:15:22 +0200 Subject: [PATCH 1042/1113] Add mac to Ambient station device (#150689) --- homeassistant/components/ambient_station/entity.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ambient_station/entity.py b/homeassistant/components/ambient_station/entity.py index 24dfab438d8..9dec905b157 100644 --- a/homeassistant/components/ambient_station/entity.py +++ b/homeassistant/components/ambient_station/entity.py @@ -5,7 +5,7 @@ from __future__ import annotations from aioambient.util import get_public_device_id from homeassistant.core import callback -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity, EntityDescription @@ -37,6 +37,7 @@ class AmbientWeatherEntity(Entity): identifiers={(DOMAIN, mac_address)}, manufacturer="Ambient Weather", name=station_name.capitalize(), + connections={(CONNECTION_NETWORK_MAC, mac_address)}, ) self._attr_unique_id = f"{mac_address}_{description.key}" From b7ba99ed176844b893992e497170fe194ede11cd Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 15 Aug 2025 15:24:05 +0200 Subject: [PATCH 1043/1113] Bump `nextdns` to version 4.1.0 (#150706) --- homeassistant/components/nextdns/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/nextdns/__init__.py | 1 + tests/components/nextdns/snapshots/test_diagnostics.ambr | 1 + 5 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json index d10a1728a94..4fdbcdb7175 100644 --- a/homeassistant/components/nextdns/manifest.json +++ b/homeassistant/components/nextdns/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["nextdns"], - "requirements": ["nextdns==4.0.0"] + "requirements": ["nextdns==4.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6112d8d49b5..9ccdcadf116 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1515,7 +1515,7 @@ nextcloudmonitor==1.5.1 nextcord==3.1.0 # homeassistant.components.nextdns -nextdns==4.0.0 +nextdns==4.1.0 # homeassistant.components.niko_home_control nhc==0.4.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b1dce41c9c4..9cbec152176 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1298,7 +1298,7 @@ nextcloudmonitor==1.5.1 nextcord==3.1.0 # homeassistant.components.nextdns -nextdns==4.0.0 +nextdns==4.1.0 # homeassistant.components.niko_home_control nhc==0.4.12 diff --git a/tests/components/nextdns/__init__.py b/tests/components/nextdns/__init__.py index 4cf74d72e63..1fa0d234196 100644 --- a/tests/components/nextdns/__init__.py +++ b/tests/components/nextdns/__init__.py @@ -39,6 +39,7 @@ SETTINGS = Settings( ai_threat_detection=True, allow_affiliate=True, anonymized_ecs=True, + bav=True, block_bypass_methods=True, block_csam=True, block_ddns=True, diff --git a/tests/components/nextdns/snapshots/test_diagnostics.ambr b/tests/components/nextdns/snapshots/test_diagnostics.ambr index 23f42fee077..f55c381af4e 100644 --- a/tests/components/nextdns/snapshots/test_diagnostics.ambr +++ b/tests/components/nextdns/snapshots/test_diagnostics.ambr @@ -56,6 +56,7 @@ 'ai_threat_detection': True, 'allow_affiliate': True, 'anonymized_ecs': True, + 'bav': True, 'block_9gag': True, 'block_amazon': True, 'block_bereal': True, From 94e9f32da58a49c37bd9ceb7ce122001834235a8 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 15 Aug 2025 15:24:23 +0200 Subject: [PATCH 1044/1113] Bump airOS to 0.3.0 (#150693) --- homeassistant/components/airos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/airos/fixtures/airos_loco5ac_ap-ptp.json | 9 ++++++++- tests/components/airos/snapshots/test_diagnostics.ambr | 2 ++ 5 files changed, 13 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/airos/manifest.json b/homeassistant/components/airos/manifest.json index 16855d805c0..5699d082956 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.11"] + "requirements": ["airos==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9ccdcadf116..5a8912fc083 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.11 +airos==0.3.0 # homeassistant.components.airthings_ble airthings-ble==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9cbec152176..83152691f58 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.11 +airos==0.3.0 # homeassistant.components.airthings_ble airthings-ble==0.9.2 diff --git a/tests/components/airos/fixtures/airos_loco5ac_ap-ptp.json b/tests/components/airos/fixtures/airos_loco5ac_ap-ptp.json index a033a82411c..06feb3d0a55 100644 --- a/tests/components/airos/fixtures/airos_loco5ac_ap-ptp.json +++ b/tests/components/airos/fixtures/airos_loco5ac_ap-ptp.json @@ -13,8 +13,10 @@ "access_point": true, "mac": "01:23:45:67:89:AB", "mac_interface": "br0", + "mode": "point_to_point", "ptmp": false, "ptp": true, + "role": "access_point", "station": false }, "firewall": { @@ -25,9 +27,14 @@ }, "genuine": "/images/genuine.png", "gps": { + "alt": null, + "dim": null, + "dop": null, "fix": 0, "lat": 52.379894, - "lon": 4.901608 + "lon": 4.901608, + "sats": null, + "time_synced": null }, "host": { "cpuload": 10.10101, diff --git a/tests/components/airos/snapshots/test_diagnostics.ambr b/tests/components/airos/snapshots/test_diagnostics.ambr index e3c4d74a5fd..f4561ec6d99 100644 --- a/tests/components/airos/snapshots/test_diagnostics.ambr +++ b/tests/components/airos/snapshots/test_diagnostics.ambr @@ -16,8 +16,10 @@ 'access_point': True, 'mac': '**REDACTED**', 'mac_interface': 'br0', + 'mode': 'point_to_point', 'ptmp': False, 'ptp': True, + 'role': 'access_point', 'station': False, }), 'firewall': dict({ From 1e2f7cadc7b5c508cc8dcfd548e6452c554bd89e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:27:49 +0200 Subject: [PATCH 1045/1113] Add unregister hook to Vera (#150708) --- homeassistant/components/vera/entity.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/vera/entity.py b/homeassistant/components/vera/entity.py index b3013c288c1..985761f2e63 100644 --- a/homeassistant/components/vera/entity.py +++ b/homeassistant/components/vera/entity.py @@ -48,6 +48,10 @@ class VeraEntity[_DeviceTypeT: veraApi.VeraDevice](Entity): """Subscribe to updates.""" self.controller.register(self.vera_device, self._update_callback) + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe from updates.""" + self.controller.unregister(self.vera_device, self._update_callback) + def _update_callback(self, _device: _DeviceTypeT) -> None: """Update the state.""" self.schedule_update_ha_state(True) From 635cfe7d17cf214e6c99ed01c8bf24013672d2ac Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:30:01 +0200 Subject: [PATCH 1046/1113] Remove hass assignment in Openhome (#150703) --- homeassistant/components/openhome/media_player.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index 9f8840b8487..8251a06bd00 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -48,7 +48,7 @@ async def async_setup_entry( device = hass.data[DOMAIN][config_entry.entry_id] - entity = OpenhomeDevice(hass, device) + entity = OpenhomeDevice(device) async_add_entities([entity]) @@ -100,9 +100,8 @@ class OpenhomeDevice(MediaPlayerEntity): _attr_state = MediaPlayerState.PLAYING _attr_available = True - def __init__(self, hass, device): + def __init__(self, device): """Initialise the Openhome device.""" - self.hass = hass self._device = device self._attr_unique_id = device.uuid() self._source_index = {} From 9646aa232abfd5d5ae36ccef9a33827ad30f7502 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:31:29 +0200 Subject: [PATCH 1047/1113] Add serial number to Zeversolar device (#150710) --- homeassistant/components/zeversolar/entity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/zeversolar/entity.py b/homeassistant/components/zeversolar/entity.py index 18ac4dcde32..3e085d952ca 100644 --- a/homeassistant/components/zeversolar/entity.py +++ b/homeassistant/components/zeversolar/entity.py @@ -27,4 +27,5 @@ class ZeversolarEntity( identifiers={(DOMAIN, coordinator.data.serial_number)}, name="Zeversolar Sensor", manufacturer="Zeversolar", + serial_number=coordinator.data.serial_number, ) From abdb48e7cecdeded31a0595b921e3a6e68a9f349 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:32:43 +0200 Subject: [PATCH 1048/1113] Add serial number to Nobo hub devices (#150700) --- homeassistant/components/nobo_hub/select.py | 4 +++- homeassistant/components/nobo_hub/sensor.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py index c24dbe3d21d..566ff88abac 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -69,10 +69,12 @@ class NoboGlobalSelector(SelectEntity): self._override_type = override_type self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, hub.hub_serial)}, + serial_number=hub.hub_serial, name=hub.hub_info[ATTR_NAME], manufacturer=NOBO_MANUFACTURER, - model=f"Nobø Ecohub ({hub.hub_info[ATTR_HARDWARE_VERSION]})", + model="Nobø Ecohub", sw_version=hub.hub_info[ATTR_SOFTWARE_VERSION], + hw_version=hub.hub_info[ATTR_HARDWARE_VERSION], ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py index 382fd1b0bf4..6a394f23f4c 100644 --- a/homeassistant/components/nobo_hub/sensor.py +++ b/homeassistant/components/nobo_hub/sensor.py @@ -58,6 +58,7 @@ class NoboTemperatureSensor(SensorEntity): suggested_area = hub.zones[zone_id][ATTR_NAME] self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, component[ATTR_SERIAL])}, + serial_number=component[ATTR_SERIAL], name=component[ATTR_NAME], manufacturer=NOBO_MANUFACTURER, model=component[ATTR_MODEL].name, From ef7ed026dbe6f7790f6ec8e30d7f9f55ee0e7928 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:33:13 +0200 Subject: [PATCH 1049/1113] Add serial number to Ondilo ICO (#150702) --- homeassistant/components/ondilo_ico/sensor.py | 6 ++++-- tests/components/ondilo_ico/snapshots/test_init.ambr | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index da5ccae11a5..42e65bd0db2 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -137,9 +137,11 @@ class OndiloICO(CoordinatorEntity[OndiloIcoMeasuresCoordinator], SensorEntity): super().__init__(coordinator) self.entity_description = description self._pool_id = pool_id - self._attr_unique_id = f"{pool_data.ico['serial_number']}-{description.key}" + serial_number = pool_data.ico["serial_number"] + self._attr_unique_id = f"{serial_number}-{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, pool_data.ico["serial_number"])}, + identifiers={(DOMAIN, serial_number)}, + serial_number=serial_number, ) @property diff --git a/tests/components/ondilo_ico/snapshots/test_init.ambr b/tests/components/ondilo_ico/snapshots/test_init.ambr index 6ea2ad11103..c3d8d92a9d2 100644 --- a/tests/components/ondilo_ico/snapshots/test_init.ambr +++ b/tests/components/ondilo_ico/snapshots/test_init.ambr @@ -25,7 +25,7 @@ 'name': 'Pool 1', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': 'W1122333044455', 'sw_version': '1.7.1-stable', 'via_device_id': None, }) @@ -56,7 +56,7 @@ 'name': 'Pool 2', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': 'W2233304445566', 'sw_version': '1.7.1-stable', 'via_device_id': None, }) From 61de50dfc002e413df846b47bcdf8923f166927a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:34:10 +0200 Subject: [PATCH 1050/1113] Add hw_version to Point device (#150704) --- homeassistant/components/point/entity.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/point/entity.py b/homeassistant/components/point/entity.py index 39af7867e97..b6718d7fd2d 100644 --- a/homeassistant/components/point/entity.py +++ b/homeassistant/components/point/entity.py @@ -28,8 +28,9 @@ class MinutPointEntity(CoordinatorEntity[PointDataUpdateCoordinator]): connections={(dr.CONNECTION_NETWORK_MAC, device["device_mac"])}, identifiers={(DOMAIN, device["device_id"])}, manufacturer="Minut", - model=f"Point v{device['hardware_version']}", + model="Point", name=device["description"], + hw_version=device["hardware_version"], sw_version=device["firmware"]["installed"], via_device=(DOMAIN, device["home"]), ) From f72f2a326a5250382de12d8afb88f7964638f59f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:34:31 +0200 Subject: [PATCH 1051/1113] Add MAC address to Modern forms devices (#150698) --- homeassistant/components/modern_forms/entity.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modern_forms/entity.py b/homeassistant/components/modern_forms/entity.py index c8419295c1f..0fab00f8f22 100644 --- a/homeassistant/components/modern_forms/entity.py +++ b/homeassistant/components/modern_forms/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -31,6 +31,9 @@ class ModernFormsDeviceEntity(CoordinatorEntity[ModernFormsDataUpdateCoordinator """Return device information about this Modern Forms device.""" return DeviceInfo( identifiers={(DOMAIN, self.coordinator.data.info.mac_address)}, + connections={ + (CONNECTION_NETWORK_MAC, self.coordinator.data.info.mac_address) + }, name=self.coordinator.data.info.device_name, manufacturer="Modern Forms", model=self.coordinator.data.info.fan_type, From 2a62e033ddfd440d5fd38ef2bf77c7d9e0453955 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Fri, 15 Aug 2025 15:35:51 +0200 Subject: [PATCH 1052/1113] Add binary sensor platform to qbus integration (#149975) --- .../components/qbus/binary_sensor.py | 144 +++++++++++++++++ homeassistant/components/qbus/const.py | 1 + homeassistant/components/qbus/coordinator.py | 34 ++-- homeassistant/components/qbus/entity.py | 39 +++-- homeassistant/components/qbus/icons.json | 12 ++ homeassistant/components/qbus/strings.json | 8 + .../qbus/snapshots/test_binary_sensor.ambr | 146 ++++++++++++++++++ tests/components/qbus/test_binary_sensor.py | 27 ++++ 8 files changed, 383 insertions(+), 28 deletions(-) create mode 100644 homeassistant/components/qbus/binary_sensor.py create mode 100644 homeassistant/components/qbus/icons.json create mode 100644 tests/components/qbus/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/qbus/test_binary_sensor.py diff --git a/homeassistant/components/qbus/binary_sensor.py b/homeassistant/components/qbus/binary_sensor.py new file mode 100644 index 00000000000..d91b6c9cbe6 --- /dev/null +++ b/homeassistant/components/qbus/binary_sensor.py @@ -0,0 +1,144 @@ +"""Support for Qbus binary sensor.""" + +from dataclasses import dataclass +from typing import cast + +from qbusmqttapi.discovery import QbusMqttDevice, QbusMqttOutput +from qbusmqttapi.factory import QbusMqttTopicFactory +from qbusmqttapi.state import QbusMqttDeviceState, QbusMqttWeatherState + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import QbusConfigEntry +from .entity import ( + QbusEntity, + create_device_identifier, + create_unique_id, + determine_new_outputs, +) + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class QbusWeatherDescription(BinarySensorEntityDescription): + """Description for Qbus weather entities.""" + + property: str + + +_WEATHER_DESCRIPTIONS = ( + QbusWeatherDescription( + key="raining", + property="raining", + translation_key="raining", + ), + QbusWeatherDescription( + key="twilight", + property="twilight", + translation_key="twilight", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: QbusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up binary sensor entities.""" + + coordinator = entry.runtime_data + added_outputs: list[QbusMqttOutput] = [] + added_controllers: list[str] = [] + + def _create_weather_entities() -> list[BinarySensorEntity]: + new_outputs = determine_new_outputs( + coordinator, added_outputs, lambda output: output.type == "weatherstation" + ) + + return [ + QbusWeatherBinarySensor(output, description) + for output in new_outputs + for description in _WEATHER_DESCRIPTIONS + ] + + def _create_controller_entities() -> list[BinarySensorEntity]: + if coordinator.data and coordinator.data.id not in added_controllers: + added_controllers.extend(coordinator.data.id) + return [QbusControllerConnectedBinarySensor(coordinator.data)] + + return [] + + def _check_outputs() -> None: + entities = [*_create_weather_entities(), *_create_controller_entities()] + async_add_entities(entities) + + _check_outputs() + entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + + +class QbusWeatherBinarySensor(QbusEntity, BinarySensorEntity): + """Representation of a Qbus weather binary sensor.""" + + _state_cls = QbusMqttWeatherState + + entity_description: QbusWeatherDescription + + def __init__( + self, mqtt_output: QbusMqttOutput, description: QbusWeatherDescription + ) -> None: + """Initialize binary sensor entity.""" + + super().__init__(mqtt_output, id_suffix=description.key) + + self.entity_description = description + + async def _handle_state_received(self, state: QbusMqttWeatherState) -> None: + if value := state.read_property(self.entity_description.property, None): + self._attr_is_on = ( + None if value is None else cast(str, value).lower() == "true" + ) + + +class QbusControllerConnectedBinarySensor(BinarySensorEntity): + """Representation of the Qbus controller connected sensor.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_should_poll = False + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + + def __init__(self, controller: QbusMqttDevice) -> None: + """Initialize binary sensor entity.""" + self._controller = controller + + self._attr_unique_id = create_unique_id(controller.serial_number, "connected") + self._attr_device_info = DeviceInfo( + identifiers={create_device_identifier(controller)} + ) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + topic = QbusMqttTopicFactory().get_device_state_topic(self._controller.id) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{topic}", + self._state_received, + ) + ) + + @callback + def _state_received(self, state: QbusMqttDeviceState) -> None: + self._attr_is_on = state.properties.connected if state.properties else None + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/qbus/const.py b/homeassistant/components/qbus/const.py index 133a3b8fea9..3ecab64059a 100644 --- a/homeassistant/components/qbus/const.py +++ b/homeassistant/components/qbus/const.py @@ -6,6 +6,7 @@ from homeassistant.const import Platform DOMAIN: Final = "qbus" PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.COVER, Platform.LIGHT, diff --git a/homeassistant/components/qbus/coordinator.py b/homeassistant/components/qbus/coordinator.py index 42e226c8e6a..c3fbf4b60bb 100644 --- a/homeassistant/components/qbus/coordinator.py +++ b/homeassistant/components/qbus/coordinator.py @@ -6,7 +6,7 @@ from datetime import datetime import logging from typing import cast -from qbusmqttapi.discovery import QbusDiscovery, QbusMqttDevice, QbusMqttOutput +from qbusmqttapi.discovery import QbusDiscovery, QbusMqttDevice from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory from homeassistant.components.mqtt import ( @@ -19,6 +19,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.hass_dict import HassKey @@ -32,7 +33,7 @@ type QbusConfigEntry = ConfigEntry[QbusControllerCoordinator] QBUS_KEY: HassKey[QbusConfigCoordinator] = HassKey(DOMAIN) -class QbusControllerCoordinator(DataUpdateCoordinator[list[QbusMqttOutput]]): +class QbusControllerCoordinator(DataUpdateCoordinator[QbusMqttDevice | None]): """Qbus data coordinator.""" _STATE_REQUEST_DELAY = 3 @@ -63,8 +64,8 @@ class QbusControllerCoordinator(DataUpdateCoordinator[list[QbusMqttOutput]]): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) ) - async def _async_update_data(self) -> list[QbusMqttOutput]: - return self._controller.outputs if self._controller else [] + async def _async_update_data(self) -> QbusMqttDevice | None: + return self._controller def shutdown(self, event: Event | None = None) -> None: """Shutdown Qbus coordinator.""" @@ -140,20 +141,25 @@ class QbusControllerCoordinator(DataUpdateCoordinator[list[QbusMqttOutput]]): "%s - Receiving controller state %s", self.config_entry.unique_id, msg.topic ) - if self._controller is None or self._controller_activated: + if self._controller is None: return state = self._message_factory.parse_device_state(msg.payload) - if state and state.properties and state.properties.connectable is False: - _LOGGER.debug( - "%s - Activating controller %s", self.config_entry.unique_id, state.id - ) - self._controller_activated = True - request = self._message_factory.create_device_activate_request( - self._controller - ) - await mqtt.async_publish(self.hass, request.topic, request.payload) + if state and state.properties: + async_dispatcher_send(self.hass, f"{DOMAIN}_{msg.topic}", state) + + if not self._controller_activated and state.properties.connectable is False: + _LOGGER.debug( + "%s - Activating controller %s", + self.config_entry.unique_id, + state.id, + ) + self._controller_activated = True + request = self._message_factory.create_device_activate_request( + self._controller + ) + await mqtt.async_publish(self.hass, request.topic, request.payload) def _request_entity_states(self) -> None: async def request_state(_: datetime) -> None: diff --git a/homeassistant/components/qbus/entity.py b/homeassistant/components/qbus/entity.py index 9fb481d4515..f7205a85c00 100644 --- a/homeassistant/components/qbus/entity.py +++ b/homeassistant/components/qbus/entity.py @@ -7,7 +7,7 @@ from collections.abc import Callable import re from typing import Generic, TypeVar, cast -from qbusmqttapi.discovery import QbusMqttOutput +from qbusmqttapi.discovery import QbusMqttDevice, QbusMqttOutput from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory from qbusmqttapi.state import QbusMqttState @@ -44,11 +44,15 @@ def determine_new_outputs( added_ref_ids = {k.ref_id for k in added_outputs} - new_outputs = [ - output - for output in coordinator.data - if filter_fn(output) and output.ref_id not in added_ref_ids - ] + new_outputs = ( + [ + output + for output in coordinator.data.outputs + if filter_fn(output) and output.ref_id not in added_ref_ids + ] + if coordinator.data + else [] + ) if new_outputs: added_outputs.extend(new_outputs) @@ -64,9 +68,14 @@ def format_ref_id(ref_id: str) -> str | None: return None -def create_main_device_identifier(mqtt_output: QbusMqttOutput) -> tuple[str, str]: - """Create the identifier referring to the main device this output belongs to.""" - return (DOMAIN, format_mac(mqtt_output.device.mac)) +def create_device_identifier(mqtt_device: QbusMqttDevice) -> tuple[str, str]: + """Create the device identifier.""" + return (DOMAIN, format_mac(mqtt_device.mac)) + + +def create_unique_id(serial_number: str, suffix: str) -> str: + """Create the unique id.""" + return f"ctd_{serial_number}_{suffix}" class QbusEntity(Entity, Generic[StateT], ABC): @@ -95,16 +104,18 @@ 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}" + suffix = ref_id or "" if id_suffix: - unique_id += f"_{id_suffix}" + suffix += f"_{id_suffix}" - self._attr_unique_id = unique_id + self._attr_unique_id = create_unique_id( + mqtt_output.device.serial_number, suffix + ) if link_to_main_device: self._attr_device_info = DeviceInfo( - identifiers={create_main_device_identifier(mqtt_output)} + identifiers={create_device_identifier(mqtt_output.device)} ) else: self._attr_device_info = DeviceInfo( @@ -112,7 +123,7 @@ class QbusEntity(Entity, Generic[StateT], ABC): 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), + via_device=create_device_identifier(mqtt_output.device), ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/qbus/icons.json b/homeassistant/components/qbus/icons.json new file mode 100644 index 00000000000..400a2bba935 --- /dev/null +++ b/homeassistant/components/qbus/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "binary_sensor": { + "raining": { + "default": "mdi:weather-pouring" + }, + "twilight": { + "default": "mdi:weather-sunset" + } + } + } +} diff --git a/homeassistant/components/qbus/strings.json b/homeassistant/components/qbus/strings.json index f3a0d108476..87788787baa 100644 --- a/homeassistant/components/qbus/strings.json +++ b/homeassistant/components/qbus/strings.json @@ -17,6 +17,14 @@ } }, "entity": { + "binary_sensor": { + "raining": { + "name": "Raining" + }, + "twilight": { + "name": "Twilight" + } + }, "sensor": { "daylight": { "name": "Daylight" diff --git a/tests/components/qbus/snapshots/test_binary_sensor.ambr b/tests/components/qbus/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..79b36db6639 --- /dev/null +++ b/tests/components/qbus/snapshots/test_binary_sensor.ambr @@ -0,0 +1,146 @@ +# serializer version: 1 +# name: test_binary_sensor[binary_sensor.ctd_000001-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.ctd_000001', + '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_connected', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.ctd_000001-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'CTD 000001', + }), + 'context': , + 'entity_id': 'binary_sensor.ctd_000001', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.weersensor_raining-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.weersensor_raining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Raining', + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'raining', + 'unique_id': 'ctd_000001_21007_raining', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.weersensor_raining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Weersensor Raining', + }), + 'context': , + 'entity_id': 'binary_sensor.weersensor_raining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.weersensor_twilight-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.weersensor_twilight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Twilight', + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'twilight', + 'unique_id': 'ctd_000001_21007_twilight', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.weersensor_twilight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Weersensor Twilight', + }), + 'context': , + 'entity_id': 'binary_sensor.weersensor_twilight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/qbus/test_binary_sensor.py b/tests/components/qbus/test_binary_sensor.py new file mode 100644 index 00000000000..9160bdb916e --- /dev/null +++ b/tests/components/qbus/test_binary_sensor.py @@ -0,0 +1,27 @@ +"""Test Qbus binary 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_binary_sensor( + hass: HomeAssistant, + setup_integration_deferred: Callable[[], Awaitable], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test binary sensor.""" + + with patch("homeassistant.components.qbus.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration_deferred() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 90157434839522560f6f4b9f53291b255beebaaa Mon Sep 17 00:00:00 2001 From: Alex Thompson Date: Fri, 15 Aug 2025 09:37:08 -0400 Subject: [PATCH 1053/1113] Bump tilt-ble to 0.3.1 (#150711) --- homeassistant/components/tilt_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/tilt_ble/manifest.json b/homeassistant/components/tilt_ble/manifest.json index e22c9d5a1d5..1b178cdb2a6 100644 --- a/homeassistant/components/tilt_ble/manifest.json +++ b/homeassistant/components/tilt_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/tilt_ble", "iot_class": "local_push", - "requirements": ["tilt-ble==0.2.3"] + "requirements": ["tilt-ble==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5a8912fc083..46480c71c47 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2941,7 +2941,7 @@ thinqconnect==1.0.7 tikteck==0.4 # homeassistant.components.tilt_ble -tilt-ble==0.2.3 +tilt-ble==0.3.1 # homeassistant.components.tilt_pi tilt-pi==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 83152691f58..c3456a3276f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2421,7 +2421,7 @@ thermopro-ble==0.13.1 thinqconnect==1.0.7 # homeassistant.components.tilt_ble -tilt-ble==0.2.3 +tilt-ble==0.3.1 # homeassistant.components.tilt_pi tilt-pi==0.2.1 From 6c21a14be4927d0269253fccd055e5a9b9b94434 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Aug 2025 15:37:34 +0200 Subject: [PATCH 1054/1113] Add binary sensor to 1-Wire DS2405 (#150679) --- .../components/onewire/binary_sensor.py | 7 +++ homeassistant/components/onewire/strings.json | 3 ++ tests/components/onewire/const.py | 1 + .../onewire/snapshots/test_binary_sensor.ambr | 49 +++++++++++++++++++ 4 files changed, 60 insertions(+) diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index 7d6b3e2c019..c1d34bad60e 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -41,6 +41,13 @@ class OneWireBinarySensorEntityDescription( DEVICE_BINARY_SENSORS: dict[str, tuple[OneWireBinarySensorEntityDescription, ...]] = { + "05": ( + OneWireBinarySensorEntityDescription( + key="sensed", + entity_registry_enabled_default=False, + translation_key="sensed", + ), + ), "12": tuple( OneWireBinarySensorEntityDescription( key=f"sensed.{device_key}", diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 5e7719673b1..c77f2933fe9 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -37,6 +37,9 @@ }, "entity": { "binary_sensor": { + "sensed": { + "name": "Sensed" + }, "sensed_id": { "name": "Sensed {id}" }, diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index 370bcc871c6..32804bca28e 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -15,6 +15,7 @@ MOCK_OWPROXY_DEVICES = { ATTR_INJECT_READS: { "/type": [b"DS2405"], "/PIO": [b" 1"], + "/sensed": [b" 1"], }, }, "10.111111111111": { diff --git a/tests/components/onewire/snapshots/test_binary_sensor.ambr b/tests/components/onewire/snapshots/test_binary_sensor.ambr index bce1251904a..521e5c50925 100644 --- a/tests/components/onewire/snapshots/test_binary_sensor.ambr +++ b/tests/components/onewire/snapshots/test_binary_sensor.ambr @@ -1,4 +1,53 @@ # serializer version: 1 +# name: test_binary_sensors[binary_sensor.05_111111111111_sensed-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.05_111111111111_sensed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed', + 'platform': 'onewire', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sensed', + 'unique_id': '/05.111111111111/sensed', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.05_111111111111_sensed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/05.111111111111/sensed', + 'friendly_name': '05.111111111111 Sensed', + }), + 'context': , + 'entity_id': 'binary_sensor.05_111111111111_sensed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensors[binary_sensor.12_111111111111_sensed_a-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 4f20776e0e130f865fdd5ac351fb40275103b173 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 15 Aug 2025 15:44:47 +0200 Subject: [PATCH 1055/1113] Add check for dependency package names in hassfest (#150630) --- script/hassfest/requirements.py | 128 +++++++++++++++++++++++++++- tests/hassfest/test_requirements.py | 118 +++++++++++++++++++++++++ 2 files changed, 245 insertions(+), 1 deletion(-) diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 9b5334823b9..3f9cc5a0f8a 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -3,8 +3,9 @@ from __future__ import annotations from collections import deque +from collections.abc import Collection from functools import cache -from importlib.metadata import metadata +from importlib.metadata import files, metadata import json import os import re @@ -300,6 +301,64 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { }, } +FORBIDDEN_PACKAGE_NAMES: set[str] = { + "doc", + "docs", + "test", + "tests", +} +FORBIDDEN_PACKAGE_FILES_EXCEPTIONS = { + # In the form dict("domain": {"package": {"reason1", "reason2"}}) + # - domain is the integration domain + # - package is the package (can be transitive) referencing the dependency + # - reasonX should be the name of the invalid dependency + # https://github.com/jaraco/jaraco.net + "abode": {"jaraco-abode": {"jaraco-net"}}, + # https://github.com/coinbase/coinbase-advanced-py + "coinbase": {"homeassistant": {"coinbase-advanced-py"}}, + # https://github.com/ggrammar/pizzapi + "dominos": {"homeassistant": {"pizzapi"}}, + # https://github.com/u9n/dlms-cosem + "dsmr": {"dsmr-parser": {"dlms-cosem"}}, + # https://github.com/ChrisMandich/PyFlume # Fixed with >=0.7.1 + "flume": {"homeassistant": {"pyflume"}}, + # https://github.com/fortinet-solutions-cse/fortiosapi + "fortios": {"homeassistant": {"fortiosapi"}}, + # https://github.com/manzanotti/geniushub-client + "geniushub": {"homeassistant": {"geniushub-client"}}, + # https://github.com/basnijholt/aiokef + "kef": {"homeassistant": {"aiokef"}}, + # https://github.com/danifus/pyzipper + "knx": {"xknxproject": {"pyzipper"}}, + # https://github.com/hthiery/python-lacrosse + "lacrosse": {"homeassistant": {"pylacrosse"}}, + # ??? + "linode": {"homeassistant": {"linode-api"}}, + # https://github.com/timmo001/aiolyric + "lyric": {"homeassistant": {"aiolyric"}}, + # https://github.com/microBeesTech/pythonSDK/ + "microbees": {"homeassistant": {"microbeespy"}}, + # https://github.com/tiagocoutinho/async_modbus + "nibe_heatpump": {"nibe": {"async-modbus"}}, + # https://github.com/ejpenney/pyobihai + "obihai": {"homeassistant": {"pyobihai"}}, + # https://github.com/iamkubi/pydactyl + "pterodactyl": {"homeassistant": {"py-dactyl"}}, + # https://github.com/markusressel/raspyrfm-client + "raspyrfm": {"homeassistant": {"raspyrfm-client"}}, + # https://github.com/sstallion/sensorpush-api + "sensorpush_cloud": { + "homeassistant": {"sensorpush-api"}, + "sensorpush-ha": {"sensorpush-api"}, + }, + # https://github.com/smappee/pysmappee + "smappee": {"homeassistant": {"pysmappee"}}, + # https://github.com/watergate-ai/watergate-local-api-python + "watergate": {"homeassistant": {"watergate-local-api"}}, + # https://github.com/markusressel/xs1-api-client + "xs1": {"homeassistant": {"xs1-api-client"}}, +} + PYTHON_VERSION_CHECK_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # In the form dict("domain": {"package": {"dependency1", "dependency2"}}) # - domain is the integration domain @@ -311,6 +370,8 @@ PYTHON_VERSION_CHECK_EXCEPTIONS: dict[str, dict[str, set[str]]] = { }, } +_packages_checked_files_cache: dict[str, set[str]] = {} + def validate(integrations: dict[str, Integration], config: Config) -> None: """Handle requirements for integrations.""" @@ -476,6 +537,12 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]: ) needs_forbidden_package_exceptions = False + packages_checked_files: set[str] = set() + forbidden_package_files_exceptions = FORBIDDEN_PACKAGE_FILES_EXCEPTIONS.get( + integration.domain, {} + ) + needs_forbidden_package_files_exception = False + package_version_check_exceptions = PACKAGE_CHECK_VERSION_RANGE_EXCEPTIONS.get( integration.domain, {} ) @@ -517,6 +584,17 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]: f"({requires_python}) in {package}", ) + # Check package names + if package not in packages_checked_files: + packages_checked_files.add(package) + if not check_dependency_files( + integration, + "homeassistant", + package, + forbidden_package_files_exceptions.get("homeassistant", ()), + ): + needs_forbidden_package_files_exception = True + # Use inner loop to check dependencies # so we have access to the dependency parent (=current package) dependencies: dict[str, str] = item["dependencies"] @@ -540,6 +618,17 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]: ): needs_package_version_check_exception = True + # Check package names + if pkg not in packages_checked_files: + packages_checked_files.add(pkg) + if not check_dependency_files( + integration, + package, + pkg, + forbidden_package_files_exceptions.get(package, ()), + ): + needs_forbidden_package_files_exception = True + to_check.extend(dependencies) if forbidden_package_exceptions and not needs_forbidden_package_exceptions: @@ -560,6 +649,15 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]: f"Integration {integration.domain} version restrictions for Python have " "been resolved, please remove from `PYTHON_VERSION_CHECK_EXCEPTIONS`", ) + if ( + forbidden_package_files_exceptions + and not needs_forbidden_package_files_exception + ): + integration.add_error( + "requirements", + f"Integration {integration.domain} runtime files dependency exceptions " + "have been resolved, please remove from `FORBIDDEN_PACKAGE_FILES_EXCEPTIONS`", + ) return all_requirements @@ -635,6 +733,34 @@ def _is_dependency_version_range_valid( return False +def check_dependency_files( + integration: Integration, + package: str, + pkg: str, + package_exceptions: Collection[str], +) -> bool: + """Check dependency files for forbidden package names.""" + if (results := _packages_checked_files_cache.get(pkg)) is None: + top_level: set[str] = set() + for file in files(pkg) or (): + top = file.parts[0].lower() + if top.endswith((".dist-info", ".py")): + continue + top_level.add(top) + results = FORBIDDEN_PACKAGE_NAMES & top_level + _packages_checked_files_cache[pkg] = results + if not results: + return True + + for dir_name in results: + integration.add_warning_or_error( + pkg in package_exceptions, + "requirements", + f"Package {pkg} has a forbidden top level directory {dir_name} in {package}", + ) + return False + + def install_requirements(integration: Integration, requirements: set[str]) -> bool: """Install integration requirements. diff --git a/tests/hassfest/test_requirements.py b/tests/hassfest/test_requirements.py index dcd35a3aca7..944b06d3c90 100644 --- a/tests/hassfest/test_requirements.py +++ b/tests/hassfest/test_requirements.py @@ -1,5 +1,7 @@ """Tests for hassfest requirements.""" +from collections.abc import Generator +from importlib.metadata import PackagePath from pathlib import Path from unittest.mock import patch @@ -7,8 +9,11 @@ import pytest from script.hassfest.model import Config, Integration from script.hassfest.requirements import ( + FORBIDDEN_PACKAGE_NAMES, PACKAGE_CHECK_PREPARE_UPDATE, PACKAGE_CHECK_VERSION_RANGE, + _packages_checked_files_cache, + check_dependency_files, check_dependency_version_range, validate_requirements_format, ) @@ -35,6 +40,19 @@ def integration(): ) +@pytest.fixture +def mock_forbidden_package_names() -> Generator[None]: + """Fixture for FORBIDDEN_PACKAGE_NAMES.""" + # pylint: disable-next=global-statement + global FORBIDDEN_PACKAGE_NAMES # noqa: PLW0603 + original = FORBIDDEN_PACKAGE_NAMES.copy() + FORBIDDEN_PACKAGE_NAMES = {"test", "tests"} + try: + yield + finally: + FORBIDDEN_PACKAGE_NAMES = original + + def test_validate_requirements_format_with_space(integration: Integration) -> None: """Test validate requirement with space around separator.""" integration.manifest["requirements"] = ["test_package == 1"] @@ -149,3 +167,103 @@ def test_dependency_version_range_prepare_update( ) == result ) + + +@pytest.mark.usefixtures("mock_forbidden_package_names") +def test_check_dependency_files(integration: Integration) -> None: + """Test dependency files check for forbidden package names is working correctly.""" + package = "homeassistant" + pkg = "my_package" + + # Forbidden top level directories: test, tests + pkg_files = [ + PackagePath("my_package/__init__.py"), + PackagePath("my_package-1.0.0.dist-info/METADATA"), + PackagePath("tests/test_some_function.py"), + PackagePath("test/submodule/test_some_other_function.py"), + ] + with ( + patch( + "script.hassfest.requirements.files", return_value=pkg_files + ) as mock_files, + patch.dict(_packages_checked_files_cache, {}, clear=True), + ): + assert not _packages_checked_files_cache + assert check_dependency_files(integration, package, pkg, ()) is False + assert _packages_checked_files_cache[pkg] == {"tests", "test"} + assert len(integration.errors) == 2 + assert ( + f"Package {pkg} has a forbidden top level directory tests in {package}" + in x.error + for x in integration.errors + ) + assert ( + f"Package {pkg} has a forbidden top level directory test in {package}" + in x.error + for x in integration.errors + ) + integration.errors.clear() + + # Repeated call should use cache + assert check_dependency_files(integration, package, pkg, ()) is False + assert mock_files.call_count == 1 + assert len(integration.errors) == 2 + integration.errors.clear() + + # Exceptions set + pkg_files = [ + PackagePath("my_package/__init__.py"), + PackagePath("my_package.dist-info/METADATA"), + PackagePath("tests/test_some_function.py"), + ] + with ( + patch( + "script.hassfest.requirements.files", return_value=pkg_files + ) as mock_files, + patch.dict(_packages_checked_files_cache, {}, clear=True), + ): + assert not _packages_checked_files_cache + assert ( + check_dependency_files(integration, package, pkg, package_exceptions={pkg}) + is False + ) + assert _packages_checked_files_cache[pkg] == {"tests"} + assert len(integration.errors) == 0 + assert len(integration.warnings) == 1 + assert ( + f"Package {pkg} has a forbidden top level directory tests in {package}" + in x.error + for x in integration.warnings + ) + integration.warnings.clear() + + # Repeated call should use cache + assert ( + check_dependency_files(integration, package, pkg, package_exceptions={pkg}) + is False + ) + assert mock_files.call_count == 1 + assert len(integration.errors) == 0 + assert len(integration.warnings) == 1 + integration.warnings.clear() + + # All good + pkg_files = [ + PackagePath("my_package/__init__.py"), + PackagePath("my_package.dist-info/METADATA"), + ] + with ( + patch( + "script.hassfest.requirements.files", return_value=pkg_files + ) as mock_files, + patch.dict(_packages_checked_files_cache, {}, clear=True), + ): + assert not _packages_checked_files_cache + assert check_dependency_files(integration, package, pkg, ()) is True + assert _packages_checked_files_cache[pkg] == set() + assert len(integration.errors) == 0 + + # Repeated call should use cache + assert check_dependency_files(integration, package, pkg, ()) is True + assert mock_files.call_count == 1 + assert len(integration.errors) == 0 From eaedefe1055dcf6a96800d8dea6f620fa8581697 Mon Sep 17 00:00:00 2001 From: Nick Kuiper <65495045+NickKoepr@users.noreply.github.com> Date: Fri, 15 Aug 2025 15:45:40 +0200 Subject: [PATCH 1056/1113] Update bluecurrent-api to 1.3.1 (#150559) --- homeassistant/components/blue_current/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/blue_current/manifest.json b/homeassistant/components/blue_current/manifest.json index 84604c62951..7c76657eb79 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.4"] + "requirements": ["bluecurrent-api==1.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 46480c71c47..810643c99bb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -643,7 +643,7 @@ blinkpy==0.23.0 blockchain==1.4.4 # homeassistant.components.blue_current -bluecurrent-api==1.2.4 +bluecurrent-api==1.3.1 # homeassistant.components.bluemaestro bluemaestro-ble==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c3456a3276f..6943a3c83ae 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -574,7 +574,7 @@ blebox-uniapi==2.5.0 blinkpy==0.23.0 # homeassistant.components.blue_current -bluecurrent-api==1.2.4 +bluecurrent-api==1.3.1 # homeassistant.components.bluemaestro bluemaestro-ble==0.4.1 From 7670146faf11957865cabdf7df6768e41609cb29 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 15 Aug 2025 15:51:48 +0200 Subject: [PATCH 1057/1113] Improve handling decode errors in rest (#150699) --- homeassistant/components/rest/data.py | 16 ++++++-- tests/components/rest/test_data.py | 57 +++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 3341f296fb9..2964ef73d46 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -45,6 +45,7 @@ class RestData: self._method = method self._resource = resource self._encoding = encoding + self._force_use_set_encoding = False # Convert auth tuple to aiohttp.BasicAuth if needed if isinstance(auth, tuple) and len(auth) == 2: @@ -152,10 +153,19 @@ class RestData: # Read the response # 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: + if self._force_use_set_encoding is False and response.charset: # Let aiohttp use the charset from Content-Type header - self.data = await response.text() - else: + try: + self.data = await response.text() + except UnicodeDecodeError as ex: + self._force_use_set_encoding = True + _LOGGER.debug( + "Response charset came back as %s but could not be decoded, continue with configured encoding %s. %s", + response.charset, + self._encoding, + ex, + ) + if self._force_use_set_encoding or not response.charset: # Use configured encoding as fallback self.data = await response.text(encoding=self._encoding) self.headers = response.headers diff --git a/tests/components/rest/test_data.py b/tests/components/rest/test_data.py index 4d6bc000fac..01581c8ac68 100644 --- a/tests/components/rest/test_data.py +++ b/tests/components/rest/test_data.py @@ -1,13 +1,17 @@ """Test REST data module logging improvements.""" +from datetime import timedelta import logging +from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.rest import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -89,6 +93,59 @@ async def test_rest_data_no_warning_on_200_with_wrong_content_type( ) +async def test_rest_data_with_incorrect_charset_in_header( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that we can handle sites which provides an incorrect charset.""" + aioclient_mock.get( + "http://example.com/api", + status=200, + text="

Some html

", + headers={"Content-Type": "text/html; charset=utf-8"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "encoding": "windows-1250", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + with patch( + "tests.test_util.aiohttp.AiohttpClientMockResponse.text", + side_effect=UnicodeDecodeError("utf-8", b"", 1, 0, ""), + ): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + log_text = "Response charset came back as utf-8 but could not be decoded, continue with configured encoding windows-1250." + assert log_text in caplog.text + + caplog.clear() + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Only log once as we only try once with automatic decoding + assert log_text not in caplog.text + + async def test_rest_data_no_warning_on_success_json( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, From 793a82923603bd64ef472226fcd5fcec4022c75a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:52:01 +0200 Subject: [PATCH 1058/1113] Add serial number to Vodafone Station device (#150709) --- homeassistant/components/vodafone_station/coordinator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index 57d39151160..35c32ab2af3 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -187,4 +187,5 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): model=sensors_data.get("sys_model_name"), hw_version=sensors_data["sys_hardware_version"], sw_version=sensors_data["sys_firmware_version"], + serial_number=self.serial_number, ) From d5a74892e6f9eb2d9dc6b08588cca0a9abe81936 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:52:13 +0200 Subject: [PATCH 1059/1113] Remove unnecessary hass assignment in coordinators (#150696) Co-authored-by: Martin Hjelmare Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/android_ip_webcam/coordinator.py | 3 +-- homeassistant/components/fritz/coordinator.py | 1 - homeassistant/components/glances/coordinator.py | 1 - homeassistant/components/homeassistant_hardware/coordinator.py | 1 - homeassistant/components/lacrosse_view/coordinator.py | 1 - homeassistant/components/livisi/coordinator.py | 1 - homeassistant/components/plaato/coordinator.py | 1 - homeassistant/components/rachio/coordinator.py | 2 -- homeassistant/components/romy/coordinator.py | 1 - homeassistant/components/speedtestdotnet/coordinator.py | 3 +-- homeassistant/components/wemo/coordinator.py | 1 - 11 files changed, 2 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/android_ip_webcam/coordinator.py b/homeassistant/components/android_ip_webcam/coordinator.py index c72d6ae1177..ec701cdf7d3 100644 --- a/homeassistant/components/android_ip_webcam/coordinator.py +++ b/homeassistant/components/android_ip_webcam/coordinator.py @@ -30,10 +30,9 @@ class AndroidIPCamDataUpdateCoordinator(DataUpdateCoordinator[None]): cam: PyDroidIPCam, ) -> None: """Initialize the Android IP Webcam.""" - self.hass = hass self.cam = cam super().__init__( - self.hass, + hass, _LOGGER, config_entry=config_entry, name=f"{DOMAIN} {config_entry.data[CONF_HOST]}", diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index d8d3bbd7a53..25687f0061a 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -120,7 +120,6 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): self.fritz_guest_wifi: FritzGuestWLAN = None self.fritz_hosts: FritzHosts = None self.fritz_status: FritzStatus = None - self.hass = hass self.host = host self.mesh_role = MeshRoles.NONE self.mesh_wifi_uplink = False diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index 28cf40aae6e..5df8fe1b2e4 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -29,7 +29,6 @@ class GlancesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self, hass: HomeAssistant, entry: GlancesConfigEntry, api: Glances ) -> None: """Initialize the Glances data.""" - self.hass = hass self.host: str = entry.data[CONF_HOST] self.api = api super().__init__( diff --git a/homeassistant/components/homeassistant_hardware/coordinator.py b/homeassistant/components/homeassistant_hardware/coordinator.py index 36a2f407282..6c4b2cb38e4 100644 --- a/homeassistant/components/homeassistant_hardware/coordinator.py +++ b/homeassistant/components/homeassistant_hardware/coordinator.py @@ -40,7 +40,6 @@ class FirmwareUpdateCoordinator(DataUpdateCoordinator[FirmwareManifest]): update_interval=FIRMWARE_REFRESH_INTERVAL, config_entry=config_entry, ) - self.hass = hass self.session = session self.client = FirmwareUpdateClient(url, session) diff --git a/homeassistant/components/lacrosse_view/coordinator.py b/homeassistant/components/lacrosse_view/coordinator.py index 1499dd02900..c6f3c2312c0 100644 --- a/homeassistant/components/lacrosse_view/coordinator.py +++ b/homeassistant/components/lacrosse_view/coordinator.py @@ -42,7 +42,6 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): self.last_update = time() self.username = entry.data["username"] self.password = entry.data["password"] - self.hass = hass self.name = entry.data["name"] self.id = entry.data["id"] super().__init__( diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index 8d490dca952..1339ae7d68c 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -45,7 +45,6 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): name="Livisi devices", update_interval=timedelta(seconds=DEVICE_POLLING_DELAY), ) - self.hass = hass self.aiolivisi = aiolivisi self.websocket = Websocket(aiolivisi) self.devices: set[str] = set() diff --git a/homeassistant/components/plaato/coordinator.py b/homeassistant/components/plaato/coordinator.py index df360d50068..74ff8566729 100644 --- a/homeassistant/components/plaato/coordinator.py +++ b/homeassistant/components/plaato/coordinator.py @@ -31,7 +31,6 @@ class PlaatoCoordinator(DataUpdateCoordinator): ) -> None: """Initialize.""" self.api = Plaato(auth_token=auth_token) - self.hass = hass self.device_type = device_type self.platforms: list[Platform] = [] diff --git a/homeassistant/components/rachio/coordinator.py b/homeassistant/components/rachio/coordinator.py index 62d42f2afda..6d482e9c900 100644 --- a/homeassistant/components/rachio/coordinator.py +++ b/homeassistant/components/rachio/coordinator.py @@ -44,7 +44,6 @@ class RachioUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): base_count: int, ) -> None: """Initialize the Rachio Update Coordinator.""" - self.hass = hass self.rachio = rachio self.base_station = base_station super().__init__( @@ -83,7 +82,6 @@ class RachioScheduleUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]] base_station, ) -> None: """Initialize a Rachio schedule coordinator.""" - self.hass = hass self.rachio = rachio self.base_station = base_station super().__init__( diff --git a/homeassistant/components/romy/coordinator.py b/homeassistant/components/romy/coordinator.py index d666ec44f80..de5352191d7 100644 --- a/homeassistant/components/romy/coordinator.py +++ b/homeassistant/components/romy/coordinator.py @@ -25,7 +25,6 @@ class RomyVacuumCoordinator(DataUpdateCoordinator[None]): name=DOMAIN, update_interval=UPDATE_INTERVAL, ) - self.hass = hass self.romy = romy async def _async_update_data(self) -> None: diff --git a/homeassistant/components/speedtestdotnet/coordinator.py b/homeassistant/components/speedtestdotnet/coordinator.py index 1308cb1d825..fac78a113f2 100644 --- a/homeassistant/components/speedtestdotnet/coordinator.py +++ b/homeassistant/components/speedtestdotnet/coordinator.py @@ -29,11 +29,10 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): api: speedtest.Speedtest, ) -> None: """Initialize the data object.""" - self.hass = hass self.api = api self.servers: dict[str, dict] = {DEFAULT_SERVER: {}} super().__init__( - self.hass, + hass, _LOGGER, config_entry=config_entry, name=DOMAIN, diff --git a/homeassistant/components/wemo/coordinator.py b/homeassistant/components/wemo/coordinator.py index 6cda83f6419..cb3c8a558b6 100644 --- a/homeassistant/components/wemo/coordinator.py +++ b/homeassistant/components/wemo/coordinator.py @@ -102,7 +102,6 @@ class DeviceCoordinator(DataUpdateCoordinator[None]): name=wemo.name, update_interval=timedelta(seconds=30), ) - self.hass = hass self.wemo = wemo self.device_id: str | None = None self.device_info = _create_device_info(wemo) From d5970e7733bc51919d8c5b96eb7d4572ce506f86 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Fri, 15 Aug 2025 16:52:36 +0300 Subject: [PATCH 1060/1113] Anthropic thinking content (#150341) --- homeassistant/components/anthropic/entity.py | 185 +++++++++--------- .../snapshots/test_conversation.ambr | 53 ++++- .../components/anthropic/test_conversation.py | 5 +- 3 files changed, 148 insertions(+), 95 deletions(-) diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index a58130ccd92..7338cbe2906 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -2,11 +2,10 @@ from collections.abc import AsyncGenerator, Callable, Iterable import json -from typing import Any, cast +from typing import Any import anthropic from anthropic import AsyncStream -from anthropic._types import NOT_GIVEN from anthropic.types import ( InputJSONDelta, MessageDeltaUsage, @@ -17,7 +16,6 @@ from anthropic.types import ( RawContentBlockStopEvent, RawMessageDeltaEvent, RawMessageStartEvent, - RawMessageStopEvent, RedactedThinkingBlock, RedactedThinkingBlockParam, SignatureDelta, @@ -35,6 +33,7 @@ from anthropic.types import ( ToolUseBlockParam, Usage, ) +from anthropic.types.message_create_params import MessageCreateParamsStreaming from voluptuous_openapi import convert from homeassistant.components import conversation @@ -129,6 +128,28 @@ def _convert_content( ) ) + if isinstance(content.native, ThinkingBlock): + messages[-1]["content"].append( # type: ignore[union-attr] + ThinkingBlockParam( + type="thinking", + thinking=content.thinking_content or "", + signature=content.native.signature, + ) + ) + elif isinstance(content.native, RedactedThinkingBlock): + redacted_thinking_block = RedactedThinkingBlockParam( + type="redacted_thinking", + data=content.native.data, + ) + if isinstance(messages[-1]["content"], str): + messages[-1]["content"] = [ + TextBlockParam(type="text", text=messages[-1]["content"]), + redacted_thinking_block, + ] + else: + messages[-1]["content"].append( # type: ignore[attr-defined] + redacted_thinking_block + ) if content.content: messages[-1]["content"].append( # type: ignore[union-attr] TextBlockParam(type="text", text=content.content) @@ -152,10 +173,9 @@ def _convert_content( return messages -async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place +async def _transform_stream( chat_log: conversation.ChatLog, - result: AsyncStream[MessageStreamEvent], - messages: list[MessageParam], + stream: AsyncStream[MessageStreamEvent], ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: """Transform the response stream into HA format. @@ -186,31 +206,25 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have Each message could contain multiple blocks of the same type. """ - if result is None: + if stream is None: raise TypeError("Expected a stream of messages") - current_message: MessageParam | None = None - current_block: ( - TextBlockParam - | ToolUseBlockParam - | ThinkingBlockParam - | RedactedThinkingBlockParam - | None - ) = None + current_tool_block: ToolUseBlockParam | None = None current_tool_args: str input_usage: Usage | None = None + has_content = False + has_native = False - async for response in result: + async for response in stream: LOGGER.debug("Received response: %s", response) if isinstance(response, RawMessageStartEvent): if response.message.role != "assistant": raise ValueError("Unexpected message role") - current_message = MessageParam(role=response.message.role, content=[]) input_usage = response.message.usage elif isinstance(response, RawContentBlockStartEvent): if isinstance(response.content_block, ToolUseBlock): - current_block = ToolUseBlockParam( + current_tool_block = ToolUseBlockParam( type="tool_use", id=response.content_block.id, name=response.content_block.name, @@ -218,75 +232,64 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have ) current_tool_args = "" elif isinstance(response.content_block, TextBlock): - current_block = TextBlockParam( - type="text", text=response.content_block.text - ) - yield {"role": "assistant"} + if has_content: + yield {"role": "assistant"} + has_native = False + has_content = True if response.content_block.text: yield {"content": response.content_block.text} elif isinstance(response.content_block, ThinkingBlock): - current_block = ThinkingBlockParam( - type="thinking", - thinking=response.content_block.thinking, - signature=response.content_block.signature, - ) + if has_native: + yield {"role": "assistant"} + has_native = False + has_content = False elif isinstance(response.content_block, RedactedThinkingBlock): - current_block = RedactedThinkingBlockParam( - type="redacted_thinking", data=response.content_block.data - ) LOGGER.debug( "Some of Claude’s internal reasoning has been automatically " "encrypted for safety reasons. This doesn’t affect the quality of " "responses" ) + if has_native: + yield {"role": "assistant"} + has_native = False + has_content = False + yield {"native": response.content_block} + has_native = True elif isinstance(response, RawContentBlockDeltaEvent): - if current_block is None: - raise ValueError("Unexpected delta without a block") if isinstance(response.delta, InputJSONDelta): current_tool_args += response.delta.partial_json elif isinstance(response.delta, TextDelta): - text_block = cast(TextBlockParam, current_block) - text_block["text"] += response.delta.text yield {"content": response.delta.text} elif isinstance(response.delta, ThinkingDelta): - thinking_block = cast(ThinkingBlockParam, current_block) - thinking_block["thinking"] += response.delta.thinking + yield {"thinking_content": response.delta.thinking} elif isinstance(response.delta, SignatureDelta): - thinking_block = cast(ThinkingBlockParam, current_block) - thinking_block["signature"] += response.delta.signature + yield { + "native": ThinkingBlock( + type="thinking", + thinking="", + signature=response.delta.signature, + ) + } + has_native = True elif isinstance(response, RawContentBlockStopEvent): - if current_block is None: - raise ValueError("Unexpected stop event without a current block") - if current_block["type"] == "tool_use": - # tool block + if current_tool_block is not None: tool_args = json.loads(current_tool_args) if current_tool_args else {} - current_block["input"] = tool_args + current_tool_block["input"] = tool_args yield { "tool_calls": [ llm.ToolInput( - id=current_block["id"], - tool_name=current_block["name"], + id=current_tool_block["id"], + tool_name=current_tool_block["name"], tool_args=tool_args, ) ] } - elif current_block["type"] == "thinking": - # thinking block - LOGGER.debug("Thinking: %s", current_block["thinking"]) - - if current_message is None: - raise ValueError("Unexpected stop event without a current message") - current_message["content"].append(current_block) # type: ignore[union-attr] - current_block = None + current_tool_block = None elif isinstance(response, RawMessageDeltaEvent): if (usage := response.usage) is not None: chat_log.async_trace(_create_token_stats(input_usage, usage)) if response.delta.stop_reason == "refusal": raise HomeAssistantError("Potential policy violation detected") - elif isinstance(response, RawMessageStopEvent): - if current_message is not None: - messages.append(current_message) - current_message = None def _create_token_stats( @@ -351,48 +354,48 @@ class AnthropicBaseLLMEntity(Entity): thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET) model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + model_args = MessageCreateParamsStreaming( + model=model, + messages=messages, + max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), + system=system.content, + stream=True, + ) + if tools: + model_args["tools"] = tools + if ( + model.startswith(tuple(THINKING_MODELS)) + and thinking_budget >= MIN_THINKING_BUDGET + ): + model_args["thinking"] = ThinkingConfigEnabledParam( + type="enabled", budget_tokens=thinking_budget + ) + else: + model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled") + model_args["temperature"] = options.get( + CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE + ) + # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): - model_args = { - "model": model, - "messages": messages, - "tools": tools or NOT_GIVEN, - "max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), - "system": system.content, - "stream": True, - } - if ( - model.startswith(tuple(THINKING_MODELS)) - and thinking_budget >= MIN_THINKING_BUDGET - ): - model_args["thinking"] = ThinkingConfigEnabledParam( - type="enabled", budget_tokens=thinking_budget - ) - else: - model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled") - model_args["temperature"] = options.get( - CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE - ) - try: stream = await client.messages.create(**model_args) + + messages.extend( + _convert_content( + [ + content + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, + _transform_stream(chat_log, stream), + ) + ] + ) + ) except anthropic.AnthropicError as err: raise HomeAssistantError( f"Sorry, I had a problem talking to Anthropic: {err}" ) from err - messages.extend( - _convert_content( - [ - content - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, - _transform_stream(chat_log, stream, messages), - ) - if not isinstance(content, conversation.AssistantContent) - ] - ) - ) - if not chat_log.unresponded_tool_results: break diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index 9afa6bf5d76..95cc02f4576 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -18,10 +18,26 @@ }), dict({ 'agent_id': 'conversation.claude_conversation', - 'content': 'Certainly, calling it now!', - 'native': None, + 'content': None, + 'native': ThinkingBlock(signature='ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', thinking='', type='thinking'), + 'role': 'assistant', + 'thinking_content': 'The user asked me to call a test function.Is it a test? What would the function do? Would it violate any privacy or security policies?', + 'tool_calls': None, + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': None, + 'native': RedactedThinkingBlock(data='EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9KWPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeVsJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOKiKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny', type='redacted_thinking'), 'role': 'assistant', 'thinking_content': None, + 'tool_calls': None, + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': 'Certainly, calling it now!', + 'native': ThinkingBlock(signature='ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', thinking='', type='thinking'), + 'role': 'assistant', + 'thinking_content': "Okay, let's give it a shot. Will I pass the test?", 'tool_calls': list([ dict({ 'id': 'toolu_0123456789AbCdEfGhIjKlM', @@ -321,6 +337,39 @@ }), ]) # --- +# name: test_redacted_thinking + list([ + dict({ + 'attachments': None, + 'content': 'ANTHROPIC_MAGIC_STRING_TRIGGER_REDACTED_THINKING_46C9A13E193C177646C7398A98432ECCCE4C1253D5E2D82641AC0E52CC2876CB', + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': None, + 'native': RedactedThinkingBlock(data='EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9KWPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeVsJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOKiKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny', type='redacted_thinking'), + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': None, + 'native': RedactedThinkingBlock(data='EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9KWPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeVsJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOKiKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny', type='redacted_thinking'), + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': 'How can I help you today?', + 'native': RedactedThinkingBlock(data='EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9KWPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeVsJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOKiKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny', type='redacted_thinking'), + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- # name: test_unknown_hass_api dict({ 'continue_conversation': False, diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 83770e7ee34..f8cccd786fc 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -728,6 +728,7 @@ async def test_redacted_thinking( hass: HomeAssistant, mock_config_entry_with_extended_thinking: MockConfigEntry, mock_init_component, + snapshot: SnapshotAssertion, ) -> None: """Test extended thinking with redacted thinking blocks.""" with patch( @@ -756,8 +757,8 @@ async def test_redacted_thinking( chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( result.conversation_id ) - assert len(chat_log.content) == 3 - assert chat_log.content[2].content == "How can I help you today?" + # Don't test the prompt because it's not deterministic + assert chat_log.content[1:] == snapshot @patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools") From 078b7224fc13f0d23119c92077f35cce16f39ea3 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 15 Aug 2025 20:46:06 +0200 Subject: [PATCH 1061/1113] Add "bypass age verification" switch to NextDNS integration (#150716) --- homeassistant/components/nextdns/strings.json | 3 ++ homeassistant/components/nextdns/switch.py | 6 +++ .../nextdns/snapshots/test_switch.ambr | 48 +++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json index 8d7bd6a215f..a9bf635673a 100644 --- a/homeassistant/components/nextdns/strings.json +++ b/homeassistant/components/nextdns/strings.json @@ -328,6 +328,9 @@ "block_zoom": { "name": "Block Zoom" }, + "bypass_age_verification": { + "name": "Bypass age verification" + }, "cache_boost": { "name": "Cache boost" }, diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py index 872f7430b3d..48151eb185c 100644 --- a/homeassistant/components/nextdns/switch.py +++ b/homeassistant/components/nextdns/switch.py @@ -56,6 +56,12 @@ SWITCHES = ( entity_category=EntityCategory.CONFIG, state=lambda data: data.anonymized_ecs, ), + NextDnsSwitchEntityDescription( + key="bav", + translation_key="bypass_age_verification", + entity_category=EntityCategory.CONFIG, + state=lambda data: data.bav, + ), NextDnsSwitchEntityDescription( key="logs", translation_key="logs", diff --git a/tests/components/nextdns/snapshots/test_switch.ambr b/tests/components/nextdns/snapshots/test_switch.ambr index 0b25baecd20..d2a78a61127 100644 --- a/tests/components/nextdns/snapshots/test_switch.ambr +++ b/tests/components/nextdns/snapshots/test_switch.ambr @@ -2879,6 +2879,54 @@ 'state': 'on', }) # --- +# name: test_switch[switch.fake_profile_bypass_age_verification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': 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.fake_profile_bypass_age_verification', + 'has_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 age verification', + 'platform': 'nextdns', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass_age_verification', + 'unique_id': 'xyz12_bav', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.fake_profile_bypass_age_verification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Fake Profile Bypass age verification', + }), + 'context': , + 'entity_id': 'switch.fake_profile_bypass_age_verification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch[switch.fake_profile_cache_boost-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 7f16b11776710aaf00b966573af7eedfe3af8e1d Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sat, 16 Aug 2025 02:40:46 -0400 Subject: [PATCH 1062/1113] Improve roborock resume cleaning logic (#150726) --- homeassistant/components/roborock/vacuum.py | 6 +++++- tests/components/roborock/test_vacuum.py | 14 +++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 4bf3c49a726..afdb3b19cb4 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -148,10 +148,14 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): async def async_start(self) -> None: """Start the vacuum.""" - if self._device_status.in_cleaning == 2: + if self._device_status.in_returning == 1: + await self.send(RoborockCommand.APP_CHARGE) + elif self._device_status.in_cleaning == 2: await self.send(RoborockCommand.RESUME_ZONED_CLEAN) elif self._device_status.in_cleaning == 3: await self.send(RoborockCommand.RESUME_SEGMENT_CLEAN) + elif self._device_status.in_cleaning == 4: + await self.send(RoborockCommand.APP_RESUME_BUILD_MAP) else: await self.send(RoborockCommand.APP_START) diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 5d6e7a599bd..aa7da07d499 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -142,12 +142,14 @@ async def test_cloud_command( @pytest.mark.parametrize( - ("in_cleaning_int", "expected_command"), + ("in_cleaning_int", "in_returning_int", "expected_command"), [ - (0, RoborockCommand.APP_START), - (1, RoborockCommand.APP_START), - (2, RoborockCommand.RESUME_ZONED_CLEAN), - (3, RoborockCommand.RESUME_SEGMENT_CLEAN), + (0, 1, RoborockCommand.APP_CHARGE), + (0, 0, RoborockCommand.APP_START), + (1, 0, RoborockCommand.APP_START), + (2, 0, RoborockCommand.RESUME_ZONED_CLEAN), + (3, 0, RoborockCommand.RESUME_SEGMENT_CLEAN), + (4, 0, RoborockCommand.APP_RESUME_BUILD_MAP), ], ) async def test_resume_cleaning( @@ -155,11 +157,13 @@ async def test_resume_cleaning( bypass_api_fixture, mock_roborock_entry: MockConfigEntry, in_cleaning_int: int, + in_returning_int: int, expected_command: RoborockCommand, ) -> None: """Test resuming clean on start button when a clean is paused.""" prop = copy.deepcopy(PROP) prop.status.in_cleaning = in_cleaning_int + prop.status.in_returning = in_returning_int with patch( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", return_value=prop, From 1aa3efaf8a3a49e6e2898561898b465618b757b0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 16 Aug 2025 08:41:28 +0200 Subject: [PATCH 1063/1113] Add support for fineDustSensor capability in SmartThings (#150714) --- .../components/smartthings/sensor.py | 10 + tests/components/smartthings/conftest.py | 1 + .../device_status/aq_sensor_3_ikea.json | 94 ++++++++ .../fixtures/devices/aq_sensor_3_ikea.json | 77 +++++++ .../smartthings/snapshots/test_init.ambr | 31 +++ .../smartthings/snapshots/test_sensor.ambr | 215 ++++++++++++++++++ .../smartthings/snapshots/test_update.ambr | 61 +++++ 7 files changed, 489 insertions(+) create mode 100644 tests/components/smartthings/fixtures/device_status/aq_sensor_3_ikea.json create mode 100644 tests/components/smartthings/fixtures/devices/aq_sensor_3_ikea.json diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index a38331d6aed..d3e2ab09a3f 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -476,6 +476,16 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, + Capability.FINE_DUST_SENSOR: { + Attribute.FINE_DUST_LEVEL: [ + SmartThingsSensorEntityDescription( + key=Attribute.FINE_DUST_LEVEL, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.PM25, + ) + ] + }, # Haven't seen at devices yet Capability.FORMALDEHYDE_MEASUREMENT: { Attribute.FORMALDEHYDE_LEVEL: [ diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 9a633a38f1a..f13617d64d5 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -96,6 +96,7 @@ def mock_smartthings() -> Generator[AsyncMock]: @pytest.fixture( params=[ + "aq_sensor_3_ikea", "da_ac_airsensor_01001", "da_ac_rac_000001", "da_ac_rac_000003", diff --git a/tests/components/smartthings/fixtures/device_status/aq_sensor_3_ikea.json b/tests/components/smartthings/fixtures/device_status/aq_sensor_3_ikea.json new file mode 100644 index 00000000000..383c5d1e85e --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/aq_sensor_3_ikea.json @@ -0,0 +1,94 @@ +{ + "components": { + "main": { + "tvocMeasurement": { + "tvocLevel": { + "value": 0.1, + "unit": "ppm", + "timestamp": "2025-08-15T13:48:52.222Z" + } + }, + "fineDustSensor": { + "fineDustLevel": { + "value": 1, + "unit": "\u03bcg/m^3", + "timestamp": "2025-08-15T13:29:36.938Z" + } + }, + "relativeHumidityMeasurement": { + "humidity": { + "value": 53.0, + "unit": "%", + "timestamp": "2025-08-15T13:48:42.554Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": { + "minimum": -10.0, + "maximum": 50.0 + }, + "unit": "C", + "timestamp": "2025-03-19T19:48:47.896Z" + }, + "temperature": { + "value": 22.0, + "unit": "C", + "timestamp": "2025-08-15T12:25:37.127Z" + } + }, + "refresh": {}, + "airQualityHealthConcern": { + "supportedAirQualityValues": { + "value": null + }, + "airQualityHealthConcern": { + "value": "good", + "timestamp": "2025-08-15T13:17:38.791Z" + } + }, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": "TOO_MANY_CLIENTS", + "timestamp": "2025-06-09T05:59:52.076Z" + }, + "imageTransferProgress": { + "value": null + }, + "availableVersion": { + "value": "00010010", + "timestamp": "2025-03-19T19:49:07.016Z" + }, + "lastUpdateStatus": { + "value": "updateFailed", + "timestamp": "2025-06-09T05:59:52.072Z" + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2025-06-09T05:59:52.105Z" + }, + "estimatedTimeRemaining": { + "value": null + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-03-19T19:49:07.014Z" + }, + "currentVersion": { + "value": "00010010", + "timestamp": "2025-06-09T05:59:52.071Z" + }, + "lastUpdateTime": { + "value": "2025-06-09T05:59:51Z", + "timestamp": "2025-06-09T05:59:52.076Z" + }, + "supportsProgressReports": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/aq_sensor_3_ikea.json b/tests/components/smartthings/fixtures/devices/aq_sensor_3_ikea.json new file mode 100644 index 00000000000..dc4c4821587 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/aq_sensor_3_ikea.json @@ -0,0 +1,77 @@ +{ + "items": [ + { + "deviceId": "e44d4e5c-45ea-498f-a653-f5d0c3d97bb8", + "name": "humidity-temp-dust-tvoc", + "label": "aq-sensor-3-ikea", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "a39f5e57-c861-3904-9567-acda80b7cf2d", + "deviceManufacturerCode": "IKEA of Sweden", + "locationId": "9fbc89a0-5c32-494d-9a49-68186d6a5387", + "ownerId": "d051c4c5-8ccb-47b9-87ee-7ebb99694b9f", + "roomId": "f5ce3177-e0a6-4415-8496-bafa1611ee62", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "fineDustSensor", + "version": 1 + }, + { + "id": "airQualityHealthConcern", + "version": 1 + }, + { + "id": "tvocMeasurement", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "AirQualityDetector", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2025-03-19T19:48:39.571Z", + "parentDeviceId": "5616f552-5bae-4a1f-94b3-9eb2673a1b28", + "profile": { + "id": "0720a973-6923-39d4-8991-3aaed6edf5d5" + }, + "zigbee": { + "eui": "0CAE5FFFFECE4328", + "networkId": "846B", + "driverId": "9bee78b3-204e-4118-8265-8767f9152c49", + "executingLocally": true, + "hubId": "5616f552-5bae-4a1f-94b3-9eb2673a1b28", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 5fd7040f61b..d732578212a 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -64,6 +64,37 @@ 'via_device_id': None, }) # --- +# name: test_devices[aq_sensor_3_ikea] + 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': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'e44d4e5c-45ea-498f-a653-f5d0c3d97bb8', + ), + }), + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'aq-sensor-3-ikea', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[aux_ac] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 8771ed505cf..dfc738bf7d7 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -163,6 +163,221 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[aq_sensor_3_ikea][sensor.aq_sensor_3_ikea_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.aq_sensor_3_ikea_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': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e44d4e5c-45ea-498f-a653-f5d0c3d97bb8_main_relativeHumidityMeasurement_humidity_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[aq_sensor_3_ikea][sensor.aq_sensor_3_ikea_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'aq-sensor-3-ikea Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aq_sensor_3_ikea_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53.0', + }) +# --- +# name: test_all_entities[aq_sensor_3_ikea][sensor.aq_sensor_3_ikea_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.aq_sensor_3_ikea_pm2_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': 'PM2.5', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e44d4e5c-45ea-498f-a653-f5d0c3d97bb8_main_fineDustSensor_fineDustLevel_fineDustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[aq_sensor_3_ikea][sensor.aq_sensor_3_ikea_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'aq-sensor-3-ikea PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.aq_sensor_3_ikea_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_all_entities[aq_sensor_3_ikea][sensor.aq_sensor_3_ikea_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.aq_sensor_3_ikea_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': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e44d4e5c-45ea-498f-a653-f5d0c3d97bb8_main_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[aq_sensor_3_ikea][sensor.aq_sensor_3_ikea_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'aq-sensor-3-ikea Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aq_sensor_3_ikea_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_all_entities[aq_sensor_3_ikea][sensor.aq_sensor_3_ikea_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.aq_sensor_3_ikea_volatile_organic_compounds_parts', + '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 parts', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e44d4e5c-45ea-498f-a653-f5d0c3d97bb8_main_tvocMeasurement_tvocLevel_tvocLevel', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_all_entities[aq_sensor_3_ikea][sensor.aq_sensor_3_ikea_volatile_organic_compounds_parts-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volatile_organic_compounds_parts', + 'friendly_name': 'aq-sensor-3-ikea Volatile organic compounds parts', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.aq_sensor_3_ikea_volatile_organic_compounds_parts', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- # name: test_all_entities[aux_ac][sensor.aux_a_c_on_off_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_update.ambr b/tests/components/smartthings/snapshots/test_update.ambr index 3191411a429..eb6b99b3363 100644 --- a/tests/components/smartthings/snapshots/test_update.ambr +++ b/tests/components/smartthings/snapshots/test_update.ambr @@ -1,4 +1,65 @@ # serializer version: 1 +# name: test_all_entities[aq_sensor_3_ikea][update.aq_sensor_3_ikea_firmware-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.aq_sensor_3_ikea_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'e44d4e5c-45ea-498f-a653-f5d0c3d97bb8_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[aq_sensor_3_ikea][update.aq_sensor_3_ikea_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'aq-sensor-3-ikea Firmware', + 'in_progress': False, + 'installed_version': '00010010', + 'latest_version': '00010010', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.aq_sensor_3_ikea_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[bosch_radiator_thermostat_ii][update.radiator_thermostat_ii_m_wohnzimmer_firmware-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From bcdece4455f5995e8e68d453e9a1da6304098fe0 Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 16 Aug 2025 08:43:47 +0200 Subject: [PATCH 1064/1113] Add additional sensors to airOS (#150712) --- homeassistant/components/airos/sensor.py | 44 +++- homeassistant/components/airos/strings.json | 20 ++ .../airos/snapshots/test_sensor.ambr | 228 ++++++++++++++++++ 3 files changed, 290 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/airos/sensor.py b/homeassistant/components/airos/sensor.py index 7b834b9c8a7..06b06a21e28 100644 --- a/homeassistant/components/airos/sensor.py +++ b/homeassistant/components/airos/sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass import logging -from airos.data import NetRole, WirelessMode +from airos.data import DerivedWirelessMode, DerivedWirelessRole, NetRole from homeassistant.components.sensor import ( SensorDeviceClass, @@ -19,6 +19,8 @@ from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS, UnitOfDataRate, UnitOfFrequency, + UnitOfLength, + UnitOfTime, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -29,8 +31,11 @@ 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] +WIRELESS_MODE_OPTIONS = [mode.value for mode in DerivedWirelessMode] +WIRELESS_ROLE_OPTIONS = [mode.value for mode in DerivedWirelessRole] + +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) @@ -118,6 +123,41 @@ SENSORS: tuple[AirOSSensorEntityDescription, ...] = ( suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, value_fn=lambda data: data.wireless.polling.ul_capacity, ), + AirOSSensorEntityDescription( + key="host_uptime", + translation_key="host_uptime", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfTime.DAYS, + value_fn=lambda data: data.host.uptime, + entity_registry_enabled_default=False, + ), + AirOSSensorEntityDescription( + key="wireless_distance", + translation_key="wireless_distance", + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfLength.KILOMETERS, + value_fn=lambda data: data.wireless.distance, + ), + AirOSSensorEntityDescription( + key="wireless_mode", + translation_key="wireless_mode", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda data: data.derived.mode.value, + options=WIRELESS_MODE_OPTIONS, + entity_registry_enabled_default=False, + ), + AirOSSensorEntityDescription( + key="wireless_role", + translation_key="wireless_role", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda data: data.derived.role.value, + options=WIRELESS_ROLE_OPTIONS, + entity_registry_enabled_default=False, + ), ) diff --git a/homeassistant/components/airos/strings.json b/homeassistant/components/airos/strings.json index b11a87d7f75..53681292f50 100644 --- a/homeassistant/components/airos/strings.json +++ b/homeassistant/components/airos/strings.json @@ -77,6 +77,26 @@ }, "wireless_remote_hostname": { "name": "Remote hostname" + }, + "host_uptime": { + "name": "Uptime" + }, + "wireless_distance": { + "name": "Wireless distance" + }, + "wireless_role": { + "name": "Wireless role", + "state": { + "access_point": "Access point", + "station": "Station" + } + }, + "wireless_mode": { + "name": "Wireless mode", + "state": { + "point_to_point": "Point-to-point", + "point_to_multipoint": "Point-to-multipoint" + } } } }, diff --git a/tests/components/airos/snapshots/test_sensor.ambr b/tests/components/airos/snapshots/test_sensor.ambr index 133b0f7f6e6..815b11ddc7e 100644 --- a/tests/components/airos/snapshots/test_sensor.ambr +++ b/tests/components/airos/snapshots/test_sensor.ambr @@ -401,6 +401,118 @@ 'state': '540.54', }) # --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_uptime-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_uptime', + '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': 'Uptime', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'host_uptime', + 'unique_id': '01:23:45:67:89:AB_host_uptime', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'NanoStation 5AC ap name Uptime', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.06583333333333', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_distance-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_distance', + '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': 'Wireless distance', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wireless_distance', + 'unique_id': '01:23:45:67:89:AB_wireless_distance', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'NanoStation 5AC ap name Wireless distance', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_frequency-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -454,6 +566,122 @@ 'state': '5500', }) # --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'point_to_point', + 'point_to_multipoint', + ]), + }), + '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([ + 'point_to_point', + 'point_to_multipoint', + ]), + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'point_to_point', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_role-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'station', + 'access_point', + ]), + }), + '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_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': 'Wireless role', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wireless_role', + 'unique_id': '01:23:45:67:89:AB_wireless_role', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_role-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'NanoStation 5AC ap name Wireless role', + 'options': list([ + 'station', + 'access_point', + ]), + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_role', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'access_point', + }) +# --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_ssid-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 616b031df8777eecb0fbe0c04ba23a962b15cd19 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 16 Aug 2025 11:00:08 +0200 Subject: [PATCH 1065/1113] Use constants in Tuya tests (#150739) --- tests/components/tuya/test_climate.py | 29 +++++++++++++----------- tests/components/tuya/test_cover.py | 25 +++++++++++--------- tests/components/tuya/test_humidifier.py | 25 ++++++++++---------- tests/components/tuya/test_light.py | 22 ++++++++++-------- tests/components/tuya/test_number.py | 18 +++++++++------ tests/components/tuya/test_select.py | 11 +++++---- tests/components/tuya/test_vacuum.py | 4 ++-- tests/components/tuya/test_valve.py | 6 ++--- 8 files changed, 77 insertions(+), 63 deletions(-) diff --git a/tests/components/tuya/test_climate.py b/tests/components/tuya/test_climate.py index 47c59267881..a0da9359ea3 100644 --- a/tests/components/tuya/test_climate.py +++ b/tests/components/tuya/test_climate.py @@ -9,13 +9,16 @@ from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_HUMIDITY, + ATTR_TEMPERATURE, 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 +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotSupported from homeassistant.helpers import entity_registry as er @@ -60,11 +63,11 @@ async def test_set_temperature( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - "entity_id": entity_id, - "temperature": 22.7, + ATTR_ENTITY_ID: entity_id, + ATTR_TEMPERATURE: 22.7, }, + blocking=True, ) - await hass.async_block_till_done() mock_manager.send_commands.assert_called_once_with( mock_device.id, [{"code": "temp_set", "value": 22}] ) @@ -86,16 +89,16 @@ async def test_fan_mode_windspeed( state = hass.states.get(entity_id) assert state is not None, f"{entity_id} does not exist" - assert state.attributes["fan_mode"] == 1 + assert state.attributes[ATTR_FAN_MODE] == 1 await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, { - "entity_id": entity_id, - "fan_mode": 2, + ATTR_ENTITY_ID: entity_id, + ATTR_FAN_MODE: 2, }, + blocking=True, ) - await hass.async_block_till_done() mock_manager.send_commands.assert_called_once_with( mock_device.id, [{"code": "windspeed", "value": "2"}] ) @@ -122,14 +125,14 @@ async def test_fan_mode_no_valid_code( 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 + assert state.attributes.get(ATTR_FAN_MODE) is None with pytest.raises(ServiceNotSupported): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, { - "entity_id": entity_id, - "fan_mode": 2, + ATTR_ENTITY_ID: entity_id, + ATTR_FAN_MODE: 2, }, blocking=True, ) @@ -156,8 +159,8 @@ async def test_set_humidity_not_supported( CLIMATE_DOMAIN, SERVICE_SET_HUMIDITY, { - "entity_id": entity_id, - "humidity": 50, + ATTR_ENTITY_ID: entity_id, + ATTR_HUMIDITY: 50, }, blocking=True, ) diff --git a/tests/components/tuya/test_cover.py b/tests/components/tuya/test_cover.py index 5b4610a6875..7206aaf1cff 100644 --- a/tests/components/tuya/test_cover.py +++ b/tests/components/tuya/test_cover.py @@ -9,6 +9,9 @@ from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + ATTR_TILT_POSITION, DOMAIN as COVER_DOMAIN, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, @@ -16,7 +19,7 @@ from homeassistant.components.cover import ( SERVICE_SET_COVER_TILT_POSITION, ) from homeassistant.components.tuya import ManagerCompat -from homeassistant.const import Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotSupported from homeassistant.helpers import entity_registry as er @@ -62,10 +65,10 @@ async def test_open_service( COVER_DOMAIN, SERVICE_OPEN_COVER, { - "entity_id": entity_id, + ATTR_ENTITY_ID: entity_id, }, + blocking=True, ) - await hass.async_block_till_done() mock_manager.send_commands.assert_called_once_with( mock_device.id, [ @@ -96,10 +99,10 @@ async def test_close_service( COVER_DOMAIN, SERVICE_CLOSE_COVER, { - "entity_id": entity_id, + ATTR_ENTITY_ID: entity_id, }, + blocking=True, ) - await hass.async_block_till_done() mock_manager.send_commands.assert_called_once_with( mock_device.id, [ @@ -129,11 +132,11 @@ async def test_set_position( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, { - "entity_id": entity_id, - "position": 25, + ATTR_ENTITY_ID: entity_id, + ATTR_POSITION: 25, }, + blocking=True, ) - await hass.async_block_till_done() mock_manager.send_commands.assert_called_once_with( mock_device.id, [ @@ -173,7 +176,7 @@ 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 + assert state.attributes[ATTR_CURRENT_POSITION] == percent_state @pytest.mark.parametrize( @@ -197,8 +200,8 @@ async def test_set_tilt_position_not_supported( COVER_DOMAIN, SERVICE_SET_COVER_TILT_POSITION, { - "entity_id": entity_id, - "tilt_position": 50, + ATTR_ENTITY_ID: entity_id, + ATTR_TILT_POSITION: 50, }, blocking=True, ) diff --git a/tests/components/tuya/test_humidifier.py b/tests/components/tuya/test_humidifier.py index 33f715176bd..c38e5521990 100644 --- a/tests/components/tuya/test_humidifier.py +++ b/tests/components/tuya/test_humidifier.py @@ -9,13 +9,14 @@ from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice from homeassistant.components.humidifier import ( + ATTR_HUMIDITY, 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.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er @@ -59,9 +60,9 @@ async def test_turn_on( await hass.services.async_call( HUMIDIFIER_DOMAIN, SERVICE_TURN_ON, - {"entity_id": entity_id}, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, ) - await hass.async_block_till_done() mock_manager.send_commands.assert_called_once_with( mock_device.id, [{"code": "switch", "value": True}] ) @@ -86,9 +87,9 @@ async def test_turn_off( await hass.services.async_call( HUMIDIFIER_DOMAIN, SERVICE_TURN_OFF, - {"entity_id": entity_id}, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, ) - await hass.async_block_till_done() mock_manager.send_commands.assert_called_once_with( mock_device.id, [{"code": "switch", "value": False}] ) @@ -114,11 +115,11 @@ async def test_set_humidity( HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, { - "entity_id": entity_id, - "humidity": 50, + ATTR_ENTITY_ID: entity_id, + ATTR_HUMIDITY: 50, }, + blocking=True, ) - await hass.async_block_till_done() mock_manager.send_commands.assert_called_once_with( mock_device.id, [{"code": "dehumidify_set_value", "value": 50}] ) @@ -149,7 +150,7 @@ async def test_turn_on_unsupported( await hass.services.async_call( HUMIDIFIER_DOMAIN, SERVICE_TURN_ON, - {"entity_id": entity_id}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) assert err.value.translation_key == "action_dpcode_not_found" @@ -184,7 +185,7 @@ async def test_turn_off_unsupported( await hass.services.async_call( HUMIDIFIER_DOMAIN, SERVICE_TURN_OFF, - {"entity_id": entity_id}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) assert err.value.translation_key == "action_dpcode_not_found" @@ -220,8 +221,8 @@ async def test_set_humidity_unsupported( HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, { - "entity_id": entity_id, - "humidity": 50, + ATTR_ENTITY_ID: entity_id, + ATTR_HUMIDITY: 50, }, blocking=True, ) diff --git a/tests/components/tuya/test_light.py b/tests/components/tuya/test_light.py index 6c36f9ef838..e87eb139385 100644 --- a/tests/components/tuya/test_light.py +++ b/tests/components/tuya/test_light.py @@ -10,12 +10,14 @@ from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_WHITE, DOMAIN as LIGHT_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) from homeassistant.components.tuya import ManagerCompat -from homeassistant.const import Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -48,7 +50,7 @@ async def test_platform_setup_and_discovery( [ ( { - "white": True, + ATTR_WHITE: True, }, [ {"code": "switch_led", "value": True}, @@ -58,7 +60,7 @@ async def test_platform_setup_and_discovery( ), ( { - "brightness": 150, + ATTR_BRIGHTNESS: 150, }, [ {"code": "switch_led", "value": True}, @@ -67,8 +69,8 @@ async def test_platform_setup_and_discovery( ), ( { - "white": True, - "brightness": 150, + ATTR_WHITE: True, + ATTR_BRIGHTNESS: 150, }, [ {"code": "switch_led", "value": True}, @@ -78,7 +80,7 @@ async def test_platform_setup_and_discovery( ), ( { - "white": 150, + ATTR_WHITE: 150, }, [ {"code": "switch_led", "value": True}, @@ -106,11 +108,11 @@ async def test_turn_on_white( LIGHT_DOMAIN, SERVICE_TURN_ON, { - "entity_id": entity_id, + ATTR_ENTITY_ID: entity_id, **turn_on_input, }, + blocking=True, ) - await hass.async_block_till_done() mock_manager.send_commands.assert_called_once_with( mock_device.id, expected_commands, @@ -137,10 +139,10 @@ async def test_turn_off( LIGHT_DOMAIN, SERVICE_TURN_OFF, { - "entity_id": entity_id, + ATTR_ENTITY_ID: entity_id, }, + blocking=True, ) - await hass.async_block_till_done() mock_manager.send_commands.assert_called_once_with( mock_device.id, [{"code": "switch_led", "value": False}] ) diff --git a/tests/components/tuya/test_number.py b/tests/components/tuya/test_number.py index 58cfe3635ea..89124fdf65f 100644 --- a/tests/components/tuya/test_number.py +++ b/tests/components/tuya/test_number.py @@ -8,9 +8,13 @@ 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.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) from homeassistant.components.tuya import ManagerCompat -from homeassistant.const import Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er @@ -55,11 +59,11 @@ async def test_set_value( NUMBER_DOMAIN, SERVICE_SET_VALUE, { - "entity_id": entity_id, - "value": 18, + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 18, }, + blocking=True, ) - await hass.async_block_till_done() mock_manager.send_commands.assert_called_once_with( mock_device.id, [{"code": "delay_set", "value": 18}] ) @@ -91,8 +95,8 @@ async def test_set_value_no_function( NUMBER_DOMAIN, SERVICE_SET_VALUE, { - "entity_id": entity_id, - "value": 18, + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 18, }, blocking=True, ) diff --git a/tests/components/tuya/test_select.py b/tests/components/tuya/test_select.py index ed585e4568f..c35963528d4 100644 --- a/tests/components/tuya/test_select.py +++ b/tests/components/tuya/test_select.py @@ -9,11 +9,12 @@ from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice from homeassistant.components.select import ( + ATTR_OPTION, DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) from homeassistant.components.tuya import ManagerCompat -from homeassistant.const import Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er @@ -58,8 +59,8 @@ async def test_select_option( SELECT_DOMAIN, SERVICE_SELECT_OPTION, { - "entity_id": entity_id, - "option": "forward", + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "forward", }, blocking=True, ) @@ -89,8 +90,8 @@ async def test_select_invalid_option( SELECT_DOMAIN, SERVICE_SELECT_OPTION, { - "entity_id": entity_id, - "option": "hello", + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "hello", }, blocking=True, ) diff --git a/tests/components/tuya/test_vacuum.py b/tests/components/tuya/test_vacuum.py index 5098927d1b4..5ee5b965137 100644 --- a/tests/components/tuya/test_vacuum.py +++ b/tests/components/tuya/test_vacuum.py @@ -13,7 +13,7 @@ from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, SERVICE_RETURN_TO_BASE, ) -from homeassistant.const import Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -58,7 +58,7 @@ async def test_return_home( VACUUM_DOMAIN, SERVICE_RETURN_TO_BASE, { - "entity_id": entity_id, + ATTR_ENTITY_ID: entity_id, }, blocking=True, ) diff --git a/tests/components/tuya/test_valve.py b/tests/components/tuya/test_valve.py index 9c00f0f4c75..73ccfba7fc4 100644 --- a/tests/components/tuya/test_valve.py +++ b/tests/components/tuya/test_valve.py @@ -14,7 +14,7 @@ from homeassistant.components.valve import ( SERVICE_CLOSE_VALVE, SERVICE_OPEN_VALVE, ) -from homeassistant.const import Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -58,7 +58,7 @@ async def test_open_valve( VALVE_DOMAIN, SERVICE_OPEN_VALVE, { - "entity_id": entity_id, + ATTR_ENTITY_ID: entity_id, }, blocking=True, ) @@ -87,7 +87,7 @@ async def test_close_valve( VALVE_DOMAIN, SERVICE_CLOSE_VALVE, { - "entity_id": entity_id, + ATTR_ENTITY_ID: entity_id, }, blocking=True, ) From 80e720f663e8f5484e752c49b41936853a022003 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sat, 16 Aug 2025 13:20:20 +0300 Subject: [PATCH 1066/1113] Add external tools support for chat log (#150461) --- .../components/conversation/__init__.py | 2 + .../components/conversation/chat_log.py | 112 +++++++++++++----- homeassistant/helpers/llm.py | 1 + .../snapshots/test_conversation.ambr | 1 + .../snapshots/test_pipeline.ambr | 1 + .../conversation/snapshots/test_chat_log.ambr | 33 ++++++ .../components/conversation/test_chat_log.py | 24 +++- .../snapshots/test_conversation.ambr | 1 + .../snapshots/test_conversation.ambr | 3 + 9 files changed, 146 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 4fd3a57034f..dec26dd3215 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -40,6 +40,7 @@ from .chat_log import ( ConverseError, SystemContent, ToolResultContent, + ToolResultContentDeltaDict, UserContent, async_get_chat_log, ) @@ -79,6 +80,7 @@ __all__ = [ "ConverseError", "SystemContent", "ToolResultContent", + "ToolResultContentDeltaDict", "UserContent", "async_conversation_trace_append", "async_converse", diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 7d842b3c562..2f5e3b0cf82 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -9,7 +9,7 @@ from contextvars import ContextVar from dataclasses import asdict, dataclass, field, replace import logging from pathlib import Path -from typing import Any, Literal, TypedDict +from typing import Any, Literal, TypedDict, cast import voluptuous as vol @@ -190,6 +190,15 @@ class AssistantContentDeltaDict(TypedDict, total=False): native: Any +class ToolResultContentDeltaDict(TypedDict, total=False): + """Tool result content.""" + + role: Literal["tool_result"] + tool_call_id: str + tool_name: str + tool_result: JsonObjectType + + @dataclass class ChatLog: """Class holding the chat history of a specific conversation.""" @@ -235,17 +244,25 @@ class ChatLog: @callback def async_add_assistant_content_without_tools( - self, content: AssistantContent + self, content: AssistantContent | ToolResultContent ) -> None: - """Add assistant content to the log.""" + """Add assistant content to the log. + + Allows assistant content without tool calls or with external tool calls only, + as well as tool results for the external tools. + """ LOGGER.debug("Adding assistant content: %s", content) - if content.tool_calls is not None: - raise ValueError("Tool calls not allowed") + if ( + isinstance(content, AssistantContent) + and content.tool_calls is not None + and any(not tool_call.external for tool_call in content.tool_calls) + ): + raise ValueError("Non-external tool calls not allowed") self.content.append(content) async def async_add_assistant_content( self, - content: AssistantContent, + content: AssistantContent | ToolResultContent, /, tool_call_tasks: dict[str, asyncio.Task] | None = None, ) -> AsyncGenerator[ToolResultContent]: @@ -258,7 +275,11 @@ class ChatLog: LOGGER.debug("Adding assistant content: %s", content) self.content.append(content) - if content.tool_calls is None: + if ( + not isinstance(content, AssistantContent) + or content.tool_calls is None + or all(tool_call.external for tool_call in content.tool_calls) + ): return if self.llm_api is None: @@ -267,13 +288,16 @@ class ChatLog: if tool_call_tasks is None: tool_call_tasks = {} for tool_input in content.tool_calls: - if tool_input.id not in tool_call_tasks: + if tool_input.id not in tool_call_tasks and not tool_input.external: tool_call_tasks[tool_input.id] = self.hass.async_create_task( self.llm_api.async_call_tool(tool_input), name=f"llm_tool_{tool_input.id}", ) for tool_input in content.tool_calls: + if tool_input.external: + continue + LOGGER.debug( "Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args ) @@ -296,7 +320,9 @@ class ChatLog: yield response_content async def async_add_delta_content_stream( - self, agent_id: str, stream: AsyncIterable[AssistantContentDeltaDict] + self, + agent_id: str, + stream: AsyncIterable[AssistantContentDeltaDict | ToolResultContentDeltaDict], ) -> AsyncGenerator[AssistantContent | ToolResultContent]: """Stream content into the chat log. @@ -320,30 +346,34 @@ class ChatLog: # Indicates update to current message if "role" not in delta: - if delta_content := delta.get("content"): + # ToolResultContentDeltaDict will always have a role + assistant_delta = cast(AssistantContentDeltaDict, delta) + if delta_content := assistant_delta.get("content"): current_content += delta_content - if delta_thinking_content := delta.get("thinking_content"): + if delta_thinking_content := assistant_delta.get("thinking_content"): current_thinking_content += delta_thinking_content - if delta_native := delta.get("native"): + if delta_native := assistant_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") + if delta_tool_calls := assistant_delta.get("tool_calls"): current_tool_calls += delta_tool_calls # Start processing the tool calls as soon as we know about them for tool_call in delta_tool_calls: - tool_call_tasks[tool_call.id] = self.hass.async_create_task( - self.llm_api.async_call_tool(tool_call), - name=f"llm_tool_{tool_call.id}", - ) + if not tool_call.external: + if self.llm_api is None: + raise ValueError("No LLM API configured") + + tool_call_tasks[tool_call.id] = self.hass.async_create_task( + self.llm_api.async_call_tool(tool_call), + name=f"llm_tool_{tool_call.id}", + ) if self.delta_listener: if filtered_delta := { - k: v for k, v in delta.items() if k != "native" + k: v for k, v in assistant_delta.items() if k != "native" }: # We do not want to send the native content to the listener # as it is not JSON serializable @@ -351,10 +381,6 @@ class ChatLog: continue # Starting a new message - - if delta["role"] != "assistant": - raise ValueError(f"Only assistant role expected. Got {delta['role']}") - # Yield the previous message if it has content if ( current_content @@ -362,7 +388,7 @@ class ChatLog: or current_tool_calls or current_native ): - content = AssistantContent( + content: AssistantContent | ToolResultContent = AssistantContent( agent_id=agent_id, content=current_content or None, thinking_content=current_thinking_content or None, @@ -376,14 +402,38 @@ class ChatLog: yield tool_result if self.delta_listener: self.delta_listener(self, asdict(tool_result)) + current_content = "" + current_thinking_content = "" + current_native = None + current_tool_calls = [] - 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 delta["role"] == "assistant": + 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 self.delta_listener: + if filtered_delta := { + k: v for k, v in delta.items() if k != "native" + }: + self.delta_listener(self, filtered_delta) + elif delta["role"] == "tool_result": + content = ToolResultContent( + agent_id=agent_id, + tool_call_id=delta["tool_call_id"], + tool_name=delta["tool_name"], + tool_result=delta["tool_result"], + ) + yield content + if self.delta_listener: + self.delta_listener(self, asdict(content)) + self.async_add_assistant_content_without_tools(content) + else: + raise ValueError( + "Only assistant and tool_result roles expected." + f" Got {delta['role']}" + ) if ( current_content diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 1ff6b188214..dc69916a728 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -183,6 +183,7 @@ class ToolInput: tool_args: dict[str, Any] # Using lambda for default to allow patching in tests id: str = dc_field(default_factory=lambda: ulid_now()) # pylint: disable=unnecessary-lambda + external: bool = False class Tool: diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index 95cc02f4576..8f7a3c43f5e 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -40,6 +40,7 @@ 'thinking_content': "Okay, let's give it a shot. Will I pass the test?", 'tool_calls': list([ dict({ + 'external': False, 'id': 'toolu_0123456789AbCdEfGhIjKlM', 'tool_args': dict({ 'param1': 'test_value', diff --git a/tests/components/assist_pipeline/snapshots/test_pipeline.ambr b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr index 95415ddb902..b6354b2342b 100644 --- a/tests/components/assist_pipeline/snapshots/test_pipeline.ambr +++ b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr @@ -467,6 +467,7 @@ 'chat_log_delta': dict({ 'tool_calls': list([ dict({ + 'external': False, 'id': 'test_tool_id', 'tool_args': dict({ }), diff --git a/tests/components/conversation/snapshots/test_chat_log.ambr b/tests/components/conversation/snapshots/test_chat_log.ambr index a1c53986053..787009ba614 100644 --- a/tests/components/conversation/snapshots/test_chat_log.ambr +++ b/tests/components/conversation/snapshots/test_chat_log.ambr @@ -16,6 +16,34 @@ }), ]) # --- +# name: test_add_delta_content_stream[deltas11] + list([ + dict({ + 'agent_id': 'mock-agent-id', + 'content': 'Test', + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': list([ + dict({ + 'external': True, + 'id': 'mock-tool-call-id', + 'tool_args': dict({ + 'param1': 'Test Param 1', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'mock-agent-id', + 'role': 'tool_result', + 'tool_call_id': 'mock-tool-call-id', + 'tool_name': 'test_tool', + 'tool_result': 'Test Result', + }), + ]) +# --- # name: test_add_delta_content_stream[deltas1] list([ dict({ @@ -58,6 +86,7 @@ 'thinking_content': None, 'tool_calls': list([ dict({ + 'external': False, 'id': 'mock-tool-call-id', 'tool_args': dict({ 'param1': 'Test Param 1', @@ -85,6 +114,7 @@ 'thinking_content': None, 'tool_calls': list([ dict({ + 'external': False, 'id': 'mock-tool-call-id', 'tool_args': dict({ 'param1': 'Test Param 1', @@ -112,6 +142,7 @@ 'thinking_content': None, 'tool_calls': list([ dict({ + 'external': False, 'id': 'mock-tool-call-id', 'tool_args': dict({ 'param1': 'Test Param 1', @@ -147,6 +178,7 @@ 'thinking_content': None, 'tool_calls': list([ dict({ + 'external': False, 'id': 'mock-tool-call-id', 'tool_args': dict({ 'param1': 'Test Param 1', @@ -154,6 +186,7 @@ 'tool_name': 'test_tool', }), dict({ + 'external': False, 'id': 'mock-tool-call-id-2', 'tool_args': dict({ 'param1': 'Test Param 2', diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index a5ed3146ddc..e851512b36e 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -538,6 +538,27 @@ async def test_tool_call_exception( {"role": "assistant"}, {"native": object()}, ], + # With external tool calls + [ + {"role": "assistant"}, + {"content": "Test"}, + { + "tool_calls": [ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="test_tool", + tool_args={"param1": "Test Param 1"}, + external=True, + ) + ] + }, + { + "role": "tool_result", + "tool_call_id": "mock-tool-call-id", + "tool_name": "test_tool", + "tool_result": "Test Result", + }, + ], ], ) async def test_add_delta_content_stream( @@ -569,7 +590,8 @@ async def test_add_delta_content_stream( for d in deltas: yield d if filtered_delta := {k: v for k, v in d.items() if k != "native"}: - expected_delta.append(filtered_delta) + if filtered_delta.get("role") != "tool_result": + expected_delta.append(filtered_delta) captured_deltas = [] diff --git a/tests/components/open_router/snapshots/test_conversation.ambr b/tests/components/open_router/snapshots/test_conversation.ambr index b60bab02ae7..19b5785a9eb 100644 --- a/tests/components/open_router/snapshots/test_conversation.ambr +++ b/tests/components/open_router/snapshots/test_conversation.ambr @@ -135,6 +135,7 @@ 'thinking_content': None, 'tool_calls': list([ dict({ + 'external': False, 'id': 'call_call_1', 'tool_args': dict({ 'param1': 'call1', diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index 7a03c484182..d33d62214ef 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -30,6 +30,7 @@ 'thinking_content': None, 'tool_calls': list([ dict({ + 'external': False, 'id': 'call_call_1', 'tool_args': dict({ 'param1': 'call1', @@ -53,6 +54,7 @@ 'thinking_content': None, 'tool_calls': list([ dict({ + 'external': False, 'id': 'call_call_2', 'tool_args': dict({ 'param1': 'call2', @@ -144,6 +146,7 @@ 'thinking_content': None, 'tool_calls': list([ dict({ + 'external': False, 'id': 'call_call_1', 'tool_args': dict({ 'param1': 'call1', From 0d5ebdb692b95fe0f5d0aab3c60329a3c2991027 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 16 Aug 2025 12:52:26 +0200 Subject: [PATCH 1067/1113] Update hassfest package exceptions (#150744) --- .github/workflows/ci.yaml | 2 +- script/hassfest/requirements.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ea03f685962..4dfcc197842 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,7 +37,7 @@ on: type: boolean env: - CACHE_VERSION: 4 + CACHE_VERSION: 5 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1 HA_SHORT_VERSION: "2025.9" diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 3f9cc5a0f8a..30369dd163a 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -95,7 +95,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # - reasonX should be the name of the invalid dependency "adax": {"adax": {"async-timeout"}, "adax-local": {"async-timeout"}}, "airthings": {"airthings-cloud": {"async-timeout"}}, - "alexa_devices": {"marisa-trie": {"setuptools"}}, "ampio": {"asmog": {"async-timeout"}}, "apache_kafka": {"aiokafka": {"async-timeout"}}, "apple_tv": {"pyatv": {"async-timeout"}}, From a71ae4db3749ec653abc7ecfbceb9f9c678e4368 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Sat, 16 Aug 2025 20:49:55 +0200 Subject: [PATCH 1068/1113] Add min/max values as extra attributes for measurements for fyta (#150562) --- homeassistant/components/fyta/const.py | 5 + homeassistant/components/fyta/sensor.py | 126 +++++++++++++----- homeassistant/components/fyta/strings.json | 58 +++++++- .../fyta/snapshots/test_sensor.ambr | 32 +++++ 4 files changed, 189 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/fyta/const.py b/homeassistant/components/fyta/const.py index bf4636a713a..9e1898f5ae6 100644 --- a/homeassistant/components/fyta/const.py +++ b/homeassistant/components/fyta/const.py @@ -2,3 +2,8 @@ DOMAIN = "fyta" CONF_EXPIRATION = "expiration" + +CONF_MAX_ACCEPTABLE = "max_acceptable" +CONF_MAX_GOOD = "max_good" +CONF_MIN_ACCEPTABLE = "min_acceptable" +CONF_MIN_GOOD = "min_good" diff --git a/homeassistant/components/fyta/sensor.py b/homeassistant/components/fyta/sensor.py index 622945ae102..d16a3eccfff 100644 --- a/homeassistant/components/fyta/sensor.py +++ b/homeassistant/components/fyta/sensor.py @@ -25,6 +25,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType +from .const import ( + CONF_MAX_ACCEPTABLE, + CONF_MAX_GOOD, + CONF_MIN_ACCEPTABLE, + CONF_MIN_GOOD, +) from .coordinator import FytaConfigEntry, FytaCoordinator from .entity import FytaPlantEntity @@ -36,6 +42,13 @@ class FytaSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[Plant], StateType | datetime] +@dataclass(frozen=True, kw_only=True) +class FytaMeasurementSensorEntityDescription(FytaSensorEntityDescription): + """Describes Fyta sensor entity.""" + + attribute_fn: Callable[[Plant], dict[str, float | None]] + + PLANT_STATUS_LIST: list[str] = ["deleted", "doing_great", "need_attention", "no_sensor"] PLANT_MEASUREMENT_STATUS_LIST: list[str] = [ "no_data", @@ -95,35 +108,6 @@ SENSORS: Final[list[FytaSensorEntityDescription]] = [ options=PLANT_MEASUREMENT_STATUS_LIST, value_fn=lambda plant: plant.salinity_status.name.lower(), ), - FytaSensorEntityDescription( - key="temperature", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda plant: plant.temperature, - ), - FytaSensorEntityDescription( - key="light", - translation_key="light", - native_unit_of_measurement="μmol/s⋅m²", - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda plant: plant.light, - ), - FytaSensorEntityDescription( - key="moisture", - native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.MOISTURE, - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda plant: plant.moisture, - ), - FytaSensorEntityDescription( - key="salinity", - translation_key="salinity", - native_unit_of_measurement=UnitOfConductivity.MILLISIEMENS_PER_CM, - device_class=SensorDeviceClass.CONDUCTIVITY, - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda plant: plant.salinity, - ), FytaSensorEntityDescription( key="ph", device_class=SensorDeviceClass.PH, @@ -152,6 +136,62 @@ SENSORS: Final[list[FytaSensorEntityDescription]] = [ ), ] +MEASUREMENT_SENSORS: Final[list[FytaMeasurementSensorEntityDescription]] = [ + FytaMeasurementSensorEntityDescription( + key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + attribute_fn=lambda plant: { + CONF_MAX_ACCEPTABLE: plant.temperature_max_acceptable, + CONF_MAX_GOOD: plant.temperature_max_good, + CONF_MIN_ACCEPTABLE: plant.temperature_min_acceptable, + CONF_MIN_GOOD: plant.temperature_min_good, + }, + value_fn=lambda plant: plant.temperature, + ), + FytaMeasurementSensorEntityDescription( + key="light", + translation_key="light", + native_unit_of_measurement="μmol/s⋅m²", + state_class=SensorStateClass.MEASUREMENT, + attribute_fn=lambda plant: { + CONF_MAX_ACCEPTABLE: plant.light_max_acceptable, + CONF_MAX_GOOD: plant.light_max_good, + CONF_MIN_ACCEPTABLE: plant.light_min_acceptable, + CONF_MIN_GOOD: plant.light_min_good, + }, + value_fn=lambda plant: plant.light, + ), + FytaMeasurementSensorEntityDescription( + key="moisture", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.MOISTURE, + state_class=SensorStateClass.MEASUREMENT, + attribute_fn=lambda plant: { + CONF_MAX_ACCEPTABLE: plant.moisture_max_acceptable, + CONF_MAX_GOOD: plant.moisture_max_good, + CONF_MIN_ACCEPTABLE: plant.moisture_min_acceptable, + CONF_MIN_GOOD: plant.moisture_min_good, + }, + value_fn=lambda plant: plant.moisture, + ), + FytaMeasurementSensorEntityDescription( + key="salinity", + translation_key="salinity", + native_unit_of_measurement=UnitOfConductivity.MILLISIEMENS_PER_CM, + device_class=SensorDeviceClass.CONDUCTIVITY, + state_class=SensorStateClass.MEASUREMENT, + attribute_fn=lambda plant: { + CONF_MAX_ACCEPTABLE: plant.salinity_max_acceptable, + CONF_MAX_GOOD: plant.salinity_max_good, + CONF_MIN_ACCEPTABLE: plant.salinity_min_acceptable, + CONF_MIN_GOOD: plant.salinity_min_good, + }, + value_fn=lambda plant: plant.salinity, + ), +] + async def async_setup_entry( hass: HomeAssistant, @@ -168,14 +208,28 @@ async def async_setup_entry( if sensor.key in dir(coordinator.data.get(plant_id)) ] + plant_entities.extend( + FytaPlantMeasurementSensor(coordinator, entry, sensor, plant_id) + for plant_id in coordinator.fyta.plant_list + for sensor in MEASUREMENT_SENSORS + if sensor.key in dir(coordinator.data.get(plant_id)) + ) + async_add_entities(plant_entities) def _async_add_new_device(plant_id: int) -> None: - async_add_entities( + plant_entities = [ FytaPlantSensor(coordinator, entry, sensor, plant_id) for sensor in SENSORS if sensor.key in dir(coordinator.data.get(plant_id)) + ] + + plant_entities.extend( + FytaPlantMeasurementSensor(coordinator, entry, sensor, plant_id) + for sensor in MEASUREMENT_SENSORS + if sensor.key in dir(coordinator.data.get(plant_id)) ) + async_add_entities(plant_entities) coordinator.new_device_callbacks.append(_async_add_new_device) @@ -190,3 +244,15 @@ class FytaPlantSensor(FytaPlantEntity, SensorEntity): """Return the state for this sensor.""" return self.entity_description.value_fn(self.plant) + + +class FytaPlantMeasurementSensor(FytaPlantSensor): + """Represents a Fyta measurement sensor.""" + + entity_description: FytaMeasurementSensorEntityDescription + + @property + def extra_state_attributes(self) -> dict[str, float | None]: + """Return the device state attributes.""" + + return self.entity_description.attribute_fn(self.plant) diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json index 67bb991a437..b0c14e0d4c1 100644 --- a/homeassistant/components/fyta/strings.json +++ b/homeassistant/components/fyta/strings.json @@ -138,10 +138,64 @@ } }, "light": { - "name": "Light" + "name": "Light", + "state_attributes": { + "max_acceptable": { "name": "Maximum acceptable" }, + "max_good": { "name": "Maximum good" }, + "min_acceptable": { "name": "Minimum acceptable" }, + "min_good": { "name": "Minimum good" } + } + }, + "moisture": { + "name": "[%key:component::sensor::entity_component::moisture::name%]", + "state_attributes": { + "max_acceptable": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::max_acceptable::name%]" + }, + "max_good": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::max_good::name%]" + }, + "min_acceptable": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::min_acceptable::name%]" + }, + "min_good": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::min_good::name%]" + } + } }, "salinity": { - "name": "Salinity" + "name": "Salinity", + "state_attributes": { + "max_acceptable": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::max_acceptable::name%]" + }, + "max_good": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::max_good::name%]" + }, + "min_acceptable": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::min_acceptable::name%]" + }, + "min_good": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::min_good::name%]" + } + } + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]", + "state_attributes": { + "max_acceptable": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::max_acceptable::name%]" + }, + "max_good": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::max_good::name%]" + }, + "min_acceptable": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::min_acceptable::name%]" + }, + "min_good": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::min_good::name%]" + } + } }, "last_fertilised": { "name": "Last fertilized" diff --git a/tests/components/fyta/snapshots/test_sensor.ambr b/tests/components/fyta/snapshots/test_sensor.ambr index 5227755d852..289927a587b 100644 --- a/tests/components/fyta/snapshots/test_sensor.ambr +++ b/tests/components/fyta/snapshots/test_sensor.ambr @@ -142,6 +142,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Gummibaum Light', + 'max_acceptable': 675.0, + 'max_good': 450.0, + 'min_acceptable': 18.0, + 'min_good': 20.0, 'state_class': , 'unit_of_measurement': 'μmol/s⋅m²', }), @@ -261,6 +265,10 @@ 'attributes': ReadOnlyDict({ 'device_class': 'moisture', 'friendly_name': 'Gummibaum Moisture', + 'max_acceptable': 80.0, + 'max_good': 70.0, + 'min_acceptable': 25.0, + 'min_good': 35.0, 'state_class': , 'unit_of_measurement': '%', }), @@ -612,6 +620,10 @@ 'attributes': ReadOnlyDict({ 'device_class': 'conductivity', 'friendly_name': 'Gummibaum Salinity', + 'max_acceptable': 1.2, + 'max_good': 1.0, + 'min_acceptable': 0.4, + 'min_good': 0.6, 'state_class': , 'unit_of_measurement': , }), @@ -782,6 +794,10 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'Gummibaum Temperature', + 'max_acceptable': 42.0, + 'max_good': 36.0, + 'min_acceptable': 10.0, + 'min_good': 17.0, 'state_class': , 'unit_of_measurement': , }), @@ -1002,6 +1018,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Kakaobaum Light', + 'max_acceptable': 675.0, + 'max_good': 450.0, + 'min_acceptable': 18.0, + 'min_good': 20.0, 'state_class': , 'unit_of_measurement': 'μmol/s⋅m²', }), @@ -1121,6 +1141,10 @@ 'attributes': ReadOnlyDict({ 'device_class': 'moisture', 'friendly_name': 'Kakaobaum Moisture', + 'max_acceptable': 80.0, + 'max_good': 70.0, + 'min_acceptable': 25.0, + 'min_good': 35.0, 'state_class': , 'unit_of_measurement': '%', }), @@ -1472,6 +1496,10 @@ 'attributes': ReadOnlyDict({ 'device_class': 'conductivity', 'friendly_name': 'Kakaobaum Salinity', + 'max_acceptable': 1.2, + 'max_good': 1.0, + 'min_acceptable': 0.4, + 'min_good': 0.6, 'state_class': , 'unit_of_measurement': , }), @@ -1642,6 +1670,10 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'Kakaobaum Temperature', + 'max_acceptable': 42.0, + 'max_good': 36.0, + 'min_acceptable': 10.0, + 'min_good': 17.0, 'state_class': , 'unit_of_measurement': , }), From fe32e749108b13eee3870e4fdf145924f062af7a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 16 Aug 2025 21:31:14 +0200 Subject: [PATCH 1069/1113] Update charset-normalizer to 3.4.3 (#150770) --- homeassistant/package_constraints.txt | 2 +- script/gen_requirements_all.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cfec93ae4b0..7420766b910 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -168,7 +168,7 @@ poetry==1000000000.0.0 # We want to skip the binary wheels for the 'charset-normalizer' packages. # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. -charset-normalizer==3.4.0 +charset-normalizer==3.4.3 # dacite: Ensure we have a version that is able to handle type unions for # NAM, Brother, and GIOS. diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 2546c871707..9b1f00385eb 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -194,7 +194,7 @@ poetry==1000000000.0.0 # We want to skip the binary wheels for the 'charset-normalizer' packages. # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. -charset-normalizer==3.4.0 +charset-normalizer==3.4.3 # dacite: Ensure we have a version that is able to handle type unions for # NAM, Brother, and GIOS. From 53889165b5a1684f7d801f7cc8b767d063689355 Mon Sep 17 00:00:00 2001 From: Yevhenii Vaskivskyi Date: Sat, 16 Aug 2025 21:32:27 +0200 Subject: [PATCH 1070/1113] Bump asusrouter to 1.19.0 (#150742) --- homeassistant/components/asuswrt/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json index 3f29e20f8da..4b6f2e40283 100644 --- a/homeassistant/components/asuswrt/manifest.json +++ b/homeassistant/components/asuswrt/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioasuswrt", "asyncssh"], - "requirements": ["aioasuswrt==1.4.0", "asusrouter==1.18.2"] + "requirements": ["aioasuswrt==1.4.0", "asusrouter==1.19.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 810643c99bb..9907a18e035 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -528,7 +528,7 @@ arris-tg2492lg==2.2.0 asmog==0.0.6 # homeassistant.components.asuswrt -asusrouter==1.18.2 +asusrouter==1.19.0 # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6943a3c83ae..4a53f5a905a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -492,7 +492,7 @@ aranet4==2.5.1 arcam-fmj==1.8.2 # homeassistant.components.asuswrt -asusrouter==1.18.2 +asusrouter==1.19.0 # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms From d642ecb302f74ae59083243ff0caf974b8f7f898 Mon Sep 17 00:00:00 2001 From: Thomas Schamm Date: Sun, 17 Aug 2025 00:37:44 +0200 Subject: [PATCH 1071/1113] Bump boschshcpy to 0.2.107 (#150754) --- homeassistant/components/bosch_shc/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bosch_shc/manifest.json b/homeassistant/components/bosch_shc/manifest.json index 0c99324efbb..bd2e127df3f 100644 --- a/homeassistant/components/bosch_shc/manifest.json +++ b/homeassistant/components/bosch_shc/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/bosch_shc", "iot_class": "local_push", "loggers": ["boschshcpy"], - "requirements": ["boschshcpy==0.2.91"], + "requirements": ["boschshcpy==0.2.107"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 9907a18e035..44348c7b07c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -670,7 +670,7 @@ bond-async==0.2.1 bosch-alarm-mode2==0.4.6 # homeassistant.components.bosch_shc -boschshcpy==0.2.91 +boschshcpy==0.2.107 # homeassistant.components.amazon_polly # homeassistant.components.route53 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4a53f5a905a..d93a9928d64 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -601,7 +601,7 @@ bond-async==0.2.1 bosch-alarm-mode2==0.4.6 # homeassistant.components.bosch_shc -boschshcpy==0.2.91 +boschshcpy==0.2.107 # homeassistant.components.aws botocore==1.37.1 From 246a181ad471e02c56f93abdeb6a084d2b86a2f2 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 17 Aug 2025 01:56:56 +0200 Subject: [PATCH 1072/1113] Fix restrict-task-creation workflow (#150774) --- .github/workflows/restrict-task-creation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/restrict-task-creation.yml b/.github/workflows/restrict-task-creation.yml index 0a6be15180b..36d9688f50a 100644 --- a/.github/workflows/restrict-task-creation.yml +++ b/.github/workflows/restrict-task-creation.yml @@ -9,7 +9,7 @@ jobs: check-authorization: runs-on: ubuntu-latest # Only run if this is a Task issue type (from the issue form) - if: github.event.issue.issue_type == 'Task' + if: github.event.issue.type.name == 'Task' steps: - name: Check if user is authorized uses: actions/github-script@v7 From a3640c5664ffc4e1f93f4e51a93610dae6f79443 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sun, 17 Aug 2025 06:30:05 +0200 Subject: [PATCH 1073/1113] feat: switch to model id for togrill (#150750) --- .../components/togrill/coordinator.py | 2 +- .../togrill/snapshots/test_init.ambr | 32 +++++++++++++++++++ tests/components/togrill/test_init.py | 7 ++++ 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 tests/components/togrill/snapshots/test_init.ambr diff --git a/homeassistant/components/togrill/coordinator.py b/homeassistant/components/togrill/coordinator.py index b79e4350e1e..6aa06260178 100644 --- a/homeassistant/components/togrill/coordinator.py +++ b/homeassistant/components/togrill/coordinator.py @@ -100,7 +100,7 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[int, Packet]]): config_entry_id=config_entry.entry_id, connections={(CONNECTION_BLUETOOTH, self.address)}, name=config_entry.data[CONF_MODEL], - model=config_entry.data[CONF_MODEL], + model_id=config_entry.data[CONF_MODEL], sw_version=get_version_string(packet_a0), ) diff --git a/tests/components/togrill/snapshots/test_init.ambr b/tests/components/togrill/snapshots/test_init.ambr new file mode 100644 index 00000000000..b461d103e73 --- /dev/null +++ b/tests/components/togrill/snapshots/test_init.ambr @@ -0,0 +1,32 @@ +# serializer version: 1 +# name: test_setup_device_present + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'bluetooth', + '00000000-0000-0000-0000-000000000001', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + }), + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': 'Pro-05', + 'name': 'Pro-05', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': '0.0', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/togrill/test_init.py b/tests/components/togrill/test_init.py index 24f19ba367e..7f441817176 100644 --- a/tests/components/togrill/test_init.py +++ b/tests/components/togrill/test_init.py @@ -7,6 +7,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import TOGRILL_SERVICE_INFO, setup_entry @@ -20,6 +21,7 @@ async def test_setup_device_present( mock_entry: MockConfigEntry, mock_client: Mock, mock_client_class: Mock, + device_registry: dr.DeviceRegistry, ) -> None: """Test that setup works with device present.""" @@ -28,6 +30,11 @@ async def test_setup_device_present( await setup_entry(hass, mock_entry, []) assert mock_entry.state is ConfigEntryState.LOADED + device = device_registry.async_get_device( + connections={(dr.CONNECTION_BLUETOOTH, TOGRILL_SERVICE_INFO.address)} + ) + assert device == snapshot + async def test_setup_device_not_present( hass: HomeAssistant, From 3b4b478afa368c15612b6e6be5dfa2d85afff200 Mon Sep 17 00:00:00 2001 From: Thomas Schamm Date: Sun, 17 Aug 2025 10:49:04 +0200 Subject: [PATCH 1074/1113] Fix for bosch_shc: 'device_registry.async_get_or_create' referencing a non existing 'via_device' (#150756) --- homeassistant/components/bosch_shc/entity.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/bosch_shc/entity.py b/homeassistant/components/bosch_shc/entity.py index 06ce45cdb3a..e0e2963c340 100644 --- a/homeassistant/components/bosch_shc/entity.py +++ b/homeassistant/components/bosch_shc/entity.py @@ -69,12 +69,7 @@ class SHCEntity(SHCBaseEntity): manufacturer=device.manufacturer, model=device.device_model, name=device.name, - via_device=( - DOMAIN, - device.parent_device_id - if device.parent_device_id is not None - else parent_id, - ), + via_device=(DOMAIN, device.root_device_id), ) super().__init__(device=device, parent_id=parent_id, entry_id=entry_id) From 7fba94747e4fdb40e3ab9fcf0c955bef39361d1c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 17 Aug 2025 14:05:58 +0200 Subject: [PATCH 1075/1113] Add Tuya test fixtures (#150793) --- tests/components/tuya/__init__.py | 7 + .../tuya/fixtures/cz_CHLZe9HQ6QIXujVN.json | 54 ++ .../tuya/fixtures/cz_iqhidxhhmgxk5eja.json | 54 ++ .../tuya/fixtures/dd_gaobbrxqiblcng2p.json | 21 + .../tuya/fixtures/dj_qoqolwtqzfuhgghq.json | 477 ++++++++++++++++++ .../tuya/fixtures/hwsb_ircs2n82vgrozoew.json | 34 ++ .../tuya/fixtures/mc_oSQljE9YDqwCwTUA.json | 35 ++ .../tuya/fixtures/qn_5ls2jw49hpczwqng.json | 21 + .../tuya/snapshots/test_climate.ambr | 61 +++ .../components/tuya/snapshots/test_init.ambr | 217 ++++++++ .../components/tuya/snapshots/test_light.ambr | 73 +++ .../tuya/snapshots/test_sensor.ambr | 48 ++ .../tuya/snapshots/test_switch.ambr | 98 ++++ 13 files changed, 1200 insertions(+) create mode 100644 tests/components/tuya/fixtures/cz_CHLZe9HQ6QIXujVN.json create mode 100644 tests/components/tuya/fixtures/cz_iqhidxhhmgxk5eja.json create mode 100644 tests/components/tuya/fixtures/dd_gaobbrxqiblcng2p.json create mode 100644 tests/components/tuya/fixtures/dj_qoqolwtqzfuhgghq.json create mode 100644 tests/components/tuya/fixtures/hwsb_ircs2n82vgrozoew.json create mode 100644 tests/components/tuya/fixtures/mc_oSQljE9YDqwCwTUA.json create mode 100644 tests/components/tuya/fixtures/qn_5ls2jw49hpczwqng.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 537fc98854a..df98bb0385f 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -37,6 +37,7 @@ DEVICE_MOCKS = [ "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_CHLZe9HQ6QIXujVN", # https://github.com/home-assistant/core/issues/149233 "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 @@ -48,6 +49,7 @@ DEVICE_MOCKS = [ "cz_hj0a5c7ckzzexu8l", # https://github.com/home-assistant/core/issues/149704 "cz_ik9sbig3mthx9hjz", # https://github.com/home-assistant/core/issues/141278 "cz_ipabufmlmodje1ws", # https://github.com/home-assistant/core/issues/63978 + "cz_iqhidxhhmgxk5eja", # https://github.com/home-assistant/core/issues/149233 "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 @@ -65,6 +67,7 @@ DEVICE_MOCKS = [ "cz_y4jnobxh", # https://github.com/orgs/home-assistant/discussions/482 "cz_z6pht25s3p0gs26q", # https://github.com/home-assistant/core/issues/63978 "dc_l3bpgg8ibsagon4x", # https://github.com/home-assistant/core/issues/149704 + "dd_gaobbrxqiblcng2p", # https://github.com/home-assistant/core/issues/149233 "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 @@ -89,6 +92,7 @@ DEVICE_MOCKS = [ "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_qoqolwtqzfuhgghq", # https://github.com/home-assistant/core/issues/149233 "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 @@ -111,6 +115,7 @@ DEVICE_MOCKS = [ "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 + "hwsb_ircs2n82vgrozoew", # https://github.com/home-assistant/core/issues/149233 "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 @@ -124,6 +129,7 @@ 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 + "mc_oSQljE9YDqwCwTUA", # https://github.com/home-assistant/core/issues/149233 "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 @@ -139,6 +145,7 @@ DEVICE_MOCKS = [ "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 + "qn_5ls2jw49hpczwqng", # https://github.com/home-assistant/core/issues/149233 "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 diff --git a/tests/components/tuya/fixtures/cz_CHLZe9HQ6QIXujVN.json b/tests/components/tuya/fixtures/cz_CHLZe9HQ6QIXujVN.json new file mode 100644 index 00000000000..2328e901065 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_CHLZe9HQ6QIXujVN.json @@ -0,0 +1,54 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "schuur", + "category": "cz", + "product_id": "CHLZe9HQ6QIXujVN", + "product_name": "Smart Plug", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2019-12-02T17:58:38+00:00", + "create_time": "2019-12-02T17:58:38+00:00", + "update_time": "2019-12-02T17:58:38+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_iqhidxhhmgxk5eja.json b/tests/components/tuya/fixtures/cz_iqhidxhhmgxk5eja.json new file mode 100644 index 00000000000..958d400eb0e --- /dev/null +++ b/tests/components/tuya/fixtures/cz_iqhidxhhmgxk5eja.json @@ -0,0 +1,54 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Powerplug 5", + "category": "cz", + "product_id": "iqhidxhhmgxk5eja", + "product_name": "Smart Plug", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2020-05-25T11:34:43+00:00", + "create_time": "2020-05-25T11:34:43+00:00", + "update_time": "2020-05-25T11:34:43+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/dd_gaobbrxqiblcng2p.json b/tests/components/tuya/fixtures/dd_gaobbrxqiblcng2p.json new file mode 100644 index 00000000000..b0135acba1c --- /dev/null +++ b/tests/components/tuya/fixtures/dd_gaobbrxqiblcng2p.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "TV Sync Backlights", + "category": "dd", + "product_id": "gaobbrxqiblcng2p", + "product_name": "TV Sync Backlights", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-08-31T10:40:08+00:00", + "create_time": "2024-08-31T10:40:08+00:00", + "update_time": "2024-08-31T10:40:08+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_qoqolwtqzfuhgghq.json b/tests/components/tuya/fixtures/dj_qoqolwtqzfuhgghq.json new file mode 100644 index 00000000000..e623ac6f7c0 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_qoqolwtqzfuhgghq.json @@ -0,0 +1,477 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Smart Bulb RGBCW", + "category": "dj", + "product_id": "qoqolwtqzfuhgghq", + "product_name": "Smart Bulb RGBCW", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2022-01-04T09:40:06+00:00", + "create_time": "2022-01-04T09:40:06+00:00", + "update_time": "2022-01-04T09:40: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, + "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 + } + } + } + }, + "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 + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "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 + } + } + } + }, + "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 + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value_v2": 1000, + "temp_value_v2": 435, + "colour_data_v2": { + "h": 35, + "s": 760, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 1000, + "h": 0, + "s": 0, + "temperature": 85, + "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/fixtures/hwsb_ircs2n82vgrozoew.json b/tests/components/tuya/fixtures/hwsb_ircs2n82vgrozoew.json new file mode 100644 index 00000000000..228f4848d5e --- /dev/null +++ b/tests/components/tuya/fixtures/hwsb_ircs2n82vgrozoew.json @@ -0,0 +1,34 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "InverFlow", + "category": "hwsb", + "product_id": "ircs2n82vgrozoew", + "product_name": "InverFlow", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-08-08T12:44:43+00:00", + "create_time": "2025-08-08T12:44:43+00:00", + "update_time": "2025-08-08T12:44:43+00:00", + "function": {}, + "status_range": { + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 3000, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "cur_power": 405 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/mc_oSQljE9YDqwCwTUA.json b/tests/components/tuya/fixtures/mc_oSQljE9YDqwCwTUA.json new file mode 100644 index 00000000000..16d51063dc1 --- /dev/null +++ b/tests/components/tuya/fixtures/mc_oSQljE9YDqwCwTUA.json @@ -0,0 +1,35 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Kippenluik", + "category": "mc", + "product_id": "oSQljE9YDqwCwTUA", + "product_name": "Door Sensor", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-10-28T09:22:24+00:00", + "create_time": "2023-10-28T09:22:24+00:00", + "update_time": "2023-10-28T09:22:24+00:00", + "function": {}, + "status_range": { + "doorcontact_state": { + "type": "Boolean", + "value": {} + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + } + }, + "status": { + "doorcontact_state": true, + "battery_state": "high" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/qn_5ls2jw49hpczwqng.json b/tests/components/tuya/fixtures/qn_5ls2jw49hpczwqng.json new file mode 100644 index 00000000000..37f16b0d40a --- /dev/null +++ b/tests/components/tuya/fixtures/qn_5ls2jw49hpczwqng.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Mr. Pure", + "category": "qn", + "product_id": "5ls2jw49hpczwqng", + "product_name": "Mr. Pure", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-08-08T12:47:09+00:00", + "create_time": "2025-08-08T12:47:09+00:00", + "update_time": "2025-08-08T12:47:09+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "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 445fb3f8cc6..e075636be4f 100644 --- a/tests/components/tuya/snapshots/test_climate.ambr +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -370,6 +370,67 @@ 'state': 'cool', }) # --- +# name: test_platform_setup_and_discovery[climate.mr_pure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + '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.mr_pure', + 'has_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.gnqwzcph94wj2sl5nq', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.mr_pure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Mr. Pure', + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_step': 1.0, + }), + 'context': , + 'entity_id': 'climate.mr_pure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[climate.smart_thermostats-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 4491ce180ac..1e7ce8bdbff 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -960,6 +960,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[AUTwCwqDY9EjlQSocm] + 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', + 'AUTwCwqDY9EjlQSocm', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Door Sensor', + 'model_id': 'oSQljE9YDqwCwTUA', + 'name': 'Kippenluik', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[CyD4ctKVrAFSSXSbjd] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1084,6 +1115,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[NVjuXIQ6QH9eZLHCzc] + 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', + 'NVjuXIQ6QH9eZLHCzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Plug', + 'model_id': 'CHLZe9HQ6QIXujVN', + 'name': 'schuur', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[O8QpxJwdme33sqn4gk] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1239,6 +1301,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[aje5kxgmhhxdihqizc] + 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', + 'aje5kxgmhhxdihqizc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Plug', + 'model_id': 'iqhidxhhmgxk5eja', + 'name': 'Powerplug 5', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[ajkdo1bm2rcmpuufjd] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -2417,6 +2510,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[gnqwzcph94wj2sl5nq] + 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', + 'gnqwzcph94wj2sl5nq', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Mr. Pure', + 'model_id': '5ls2jw49hpczwqng', + 'name': 'Mr. Pure', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[gt1q9tldv1opojrtcp] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -3936,6 +4060,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[p2gnclbiqxrbboagdd] + 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', + 'p2gnclbiqxrbboagdd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'TV Sync Backlights (unsupported)', + 'model_id': 'gaobbrxqiblcng2p', + 'name': 'TV Sync Backlights', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[p8xoxccrjbwy] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -4215,6 +4370,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[qhgghufzqtwloqoqjd] + 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', + 'qhgghufzqtwloqoqjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Bulb RGBCW', + 'model_id': 'qoqolwtqzfuhgghq', + 'name': 'Smart Bulb RGBCW', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[qi94v9dmdx4fkpncqld] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -5362,6 +5548,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[weozorgv28n2scribswh] + 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', + 'weozorgv28n2scribswh', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'InverFlow (unsupported)', + 'model_id': 'ircs2n82vgrozoew', + 'name': 'InverFlow', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[x4nogasbi8ggpb3lcd] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index 40c2b7451d2..9abbf3c40f4 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -2470,6 +2470,79 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[light.smart_bulb_rgbcw-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.smart_bulb_rgbcw', + 'has_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.qhgghufzqtwloqoqjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.smart_bulb_rgbcw-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Smart Bulb RGBCW', + '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.smart_bulb_rgbcw', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[light.solar_zijpad-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index b8e84328e1f..b2c0b92bd30 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -7079,6 +7079,54 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.kippenluik_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.kippenluik_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.AUTwCwqDY9EjlQSocmbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.kippenluik_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kippenluik Battery state', + }), + 'context': , + 'entity_id': 'sensor.kippenluik_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- # name: test_platform_setup_and_discovery[sensor.lave_linge_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 2e5d1066fef..8e139b64876 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -5804,6 +5804,55 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.powerplug_5_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.powerplug_5_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.aje5kxgmhhxdihqizcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.powerplug_5_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Powerplug 5 Socket 1', + }), + 'context': , + 'entity_id': 'switch.powerplug_5_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.pro_breeze_30l_compressor_dehumidifier_ionizer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6097,6 +6146,55 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.schuur_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.schuur_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.NVjuXIQ6QH9eZLHCzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.schuur_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'schuur Socket 1', + }), + 'context': , + 'entity_id': 'switch.schuur_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 90558c517bb991bd615e24504fe0d1502b9db183 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 17 Aug 2025 15:30:46 +0200 Subject: [PATCH 1076/1113] Add info to Bravia device (#150690) --- homeassistant/components/braviatv/button.py | 6 ++---- .../components/braviatv/config_flow.py | 6 ++++-- .../components/braviatv/coordinator.py | 4 ++++ homeassistant/components/braviatv/entity.py | 17 +++++------------ .../components/braviatv/media_player.py | 4 +--- homeassistant/components/braviatv/remote.py | 2 +- .../braviatv/snapshots/test_diagnostics.ambr | 2 +- tests/components/braviatv/test_config_flow.py | 6 +++--- tests/components/braviatv/test_diagnostics.py | 1 + 9 files changed, 22 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/braviatv/button.py b/homeassistant/components/braviatv/button.py index 20250949bcb..a1ee159290a 100644 --- a/homeassistant/components/braviatv/button.py +++ b/homeassistant/components/braviatv/button.py @@ -53,8 +53,7 @@ async def async_setup_entry( assert unique_id is not None async_add_entities( - BraviaTVButton(coordinator, unique_id, config_entry.title, description) - for description in BUTTONS + BraviaTVButton(coordinator, unique_id, description) for description in BUTTONS ) @@ -67,11 +66,10 @@ class BraviaTVButton(BraviaTVEntity, ButtonEntity): self, coordinator: BraviaTVCoordinator, unique_id: str, - model: str, description: BraviaTVButtonDescription, ) -> None: """Initialize the button.""" - super().__init__(coordinator, unique_id, model) + super().__init__(coordinator, unique_id) self._attr_unique_id = f"{unique_id}_{description.key}" self.entity_description = description diff --git a/homeassistant/components/braviatv/config_flow.py b/homeassistant/components/braviatv/config_flow.py index 5d775b98180..1a5aa1fddd6 100644 --- a/homeassistant/components/braviatv/config_flow.py +++ b/homeassistant/components/braviatv/config_flow.py @@ -79,14 +79,16 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN): system_info = await self.client.get_system_info() cid = system_info[ATTR_CID].lower() - title = system_info[ATTR_MODEL] self.device_config[CONF_MAC] = system_info[ATTR_MAC] await self.async_set_unique_id(cid) self._abort_if_unique_id_configured() - return self.async_create_entry(title=title, data=self.device_config) + return self.async_create_entry( + title=f"{system_info['name']} {system_info[ATTR_MODEL]}", + data=self.device_config, + ) async def async_reauth_device(self) -> ConfigFlowResult: """Reauthorize Bravia TV device from config.""" diff --git a/homeassistant/components/braviatv/coordinator.py b/homeassistant/components/braviatv/coordinator.py index 039726de94d..41b3923a716 100644 --- a/homeassistant/components/braviatv/coordinator.py +++ b/homeassistant/components/braviatv/coordinator.py @@ -81,6 +81,7 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): self.use_psk = config_entry.data.get(CONF_USE_PSK, False) self.client_id = config_entry.data.get(CONF_CLIENT_ID, LEGACY_CLIENT_ID) self.nickname = config_entry.data.get(CONF_NICKNAME, NICKNAME_PREFIX) + self.system_info: dict[str, str] = {} self.source: str | None = None self.source_list: list[str] = [] self.source_map: dict[str, dict] = {} @@ -150,6 +151,9 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): self.is_on = power_status == "active" self.skipped_updates = 0 + if not self.system_info: + self.system_info = await self.client.get_system_info() + if self.is_on is False: return diff --git a/homeassistant/components/braviatv/entity.py b/homeassistant/components/braviatv/entity.py index b4e370f20d2..e1c6260b070 100644 --- a/homeassistant/components/braviatv/entity.py +++ b/homeassistant/components/braviatv/entity.py @@ -12,23 +12,16 @@ class BraviaTVEntity(CoordinatorEntity[BraviaTVCoordinator]): _attr_has_entity_name = True - def __init__( - self, - coordinator: BraviaTVCoordinator, - unique_id: str, - model: str, - ) -> None: + def __init__(self, coordinator: BraviaTVCoordinator, unique_id: str) -> None: """Initialize the entity.""" super().__init__(coordinator) self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, + connections={(CONNECTION_NETWORK_MAC, coordinator.system_info["macAddr"])}, manufacturer=ATTR_MANUFACTURER, - model=model, - name=f"{ATTR_MANUFACTURER} {model}", + model_id=coordinator.system_info["model"], + hw_version=coordinator.system_info["generation"], + serial_number=coordinator.system_info["serial"], ) - if coordinator.client.mac is not None: - self._attr_device_info["connections"] = { - (CONNECTION_NETWORK_MAC, coordinator.client.mac) - } diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py index fe9c386b060..c4226190ad8 100644 --- a/homeassistant/components/braviatv/media_player.py +++ b/homeassistant/components/braviatv/media_player.py @@ -34,9 +34,7 @@ async def async_setup_entry( unique_id = config_entry.unique_id assert unique_id is not None - async_add_entities( - [BraviaTVMediaPlayer(coordinator, unique_id, config_entry.title)] - ) + async_add_entities([BraviaTVMediaPlayer(coordinator, unique_id)]) class BraviaTVMediaPlayer(BraviaTVEntity, MediaPlayerEntity): diff --git a/homeassistant/components/braviatv/remote.py b/homeassistant/components/braviatv/remote.py index 0611e367445..40f552c9258 100644 --- a/homeassistant/components/braviatv/remote.py +++ b/homeassistant/components/braviatv/remote.py @@ -24,7 +24,7 @@ async def async_setup_entry( unique_id = config_entry.unique_id assert unique_id is not None - async_add_entities([BraviaTVRemote(coordinator, unique_id, config_entry.title)]) + async_add_entities([BraviaTVRemote(coordinator, unique_id)]) class BraviaTVRemote(BraviaTVEntity, RemoteEntity): diff --git a/tests/components/braviatv/snapshots/test_diagnostics.ambr b/tests/components/braviatv/snapshots/test_diagnostics.ambr index de76c00cd23..e6bc20a2216 100644 --- a/tests/components/braviatv/snapshots/test_diagnostics.ambr +++ b/tests/components/braviatv/snapshots/test_diagnostics.ambr @@ -21,7 +21,7 @@ 'source': 'user', 'subentries': list([ ]), - 'title': 'Mock Title', + 'title': 'BRAVIA TV-Model', 'unique_id': 'very_unique_string', 'version': 1, }), diff --git a/tests/components/braviatv/test_config_flow.py b/tests/components/braviatv/test_config_flow.py index 497e88053f5..e59d0b6805b 100644 --- a/tests/components/braviatv/test_config_flow.py +++ b/tests/components/braviatv/test_config_flow.py @@ -143,7 +143,7 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].unique_id == "very_unique_string" - assert result["title"] == "TV-Model" + assert result["title"] == "BRAVIA TV-Model" assert result["data"] == { CONF_HOST: "bravia-host", CONF_PIN: "1234", @@ -340,7 +340,7 @@ async def test_create_entry(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].unique_id == "very_unique_string" - assert result["title"] == "TV-Model" + assert result["title"] == "BRAVIA TV-Model" assert result["data"] == { CONF_HOST: "bravia-host", CONF_PIN: "1234", @@ -381,7 +381,7 @@ async def test_create_entry_psk(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].unique_id == "very_unique_string" - assert result["title"] == "TV-Model" + assert result["title"] == "BRAVIA TV-Model" assert result["data"] == { CONF_HOST: "bravia-host", CONF_PIN: "mypsk", diff --git a/tests/components/braviatv/test_diagnostics.py b/tests/components/braviatv/test_diagnostics.py index 2f6df722909..ecaa82678e6 100644 --- a/tests/components/braviatv/test_diagnostics.py +++ b/tests/components/braviatv/test_diagnostics.py @@ -46,6 +46,7 @@ async def test_entry_diagnostics( config_entry = MockConfigEntry( domain=DOMAIN, + title="BRAVIA TV-Model", data={ CONF_HOST: "localhost", CONF_MAC: "AA:BB:CC:DD:EE:FF", From e90183391ecb74592d2e438ddedb47e9608e64fe Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sun, 17 Aug 2025 16:09:24 +0200 Subject: [PATCH 1077/1113] Modbus: Delay start after connection is made. (#150526) --- homeassistant/components/modbus/modbus.py | 17 +++++++++-------- tests/components/modbus/test_init.py | 1 + 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 1bd17f17b36..186720bb40a 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -317,12 +317,19 @@ class ModbusHub: try: await self._client.connect() # type: ignore[union-attr] except ModbusException as exception_error: - err = f"{self.name} connect failed, retry in pymodbus ({exception_error!s})" - self._log_error(err) + self._log_error( + f"{self.name} connect failed, please check your configuration ({exception_error!s})" + ) return message = f"modbus {self.name} communication open" _LOGGER.info(message) + # Start counting down to allow modbus requests. + if self._config_delay: + self._async_cancel_listener = async_call_later( + self.hass, self._config_delay, self.async_end_delay + ) + async def async_setup(self) -> bool: """Set up pymodbus client.""" try: @@ -340,12 +347,6 @@ class ModbusHub: self._connect_task = self.hass.async_create_background_task( self.async_pb_connect(), "modbus-connect" ) - - # Start counting down to allow modbus requests. - if self._config_delay: - self._async_cancel_listener = async_call_later( - self.hass, self._config_delay, self.async_end_delay - ) return True @callback diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index be92e12c700..3896d34146a 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -1225,6 +1225,7 @@ async def test_integration_reload( assert not state_sensor_2 +@pytest.mark.skip @pytest.mark.parametrize("do_config", [{}]) async def test_integration_reload_failed( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus From 34964942907abd254a7d8743fefe8337a0f6610d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 17 Aug 2025 16:15:02 +0200 Subject: [PATCH 1078/1113] Remove filters from device analytics payload (#150771) --- .../components/analytics/analytics.py | 24 ++---- tests/components/analytics/test_analytics.py | 74 +++++++++++++++++-- 2 files changed, 72 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 0d0f5183566..8b276021d38 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -390,7 +390,6 @@ def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]: 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 @@ -400,10 +399,6 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: seen_integrations = set() for device in dev_reg.devices.values(): - # Ignore services - if device.entry_type: - continue - if not device.primary_config_entry: continue @@ -414,13 +409,6 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: 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( { @@ -432,8 +420,10 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: "hw_version": device.hw_version, "has_configuration_url": device.configuration_url is not None, "via_device": None, + "entry_type": device.entry_type.value if device.entry_type else None, } ) + if device.via_device_id: via_devices[device.id] = device.via_device_id @@ -453,15 +443,11 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: for device_info in devices: if integration := integrations.get(device_info["integration"]): device_info["is_custom_integration"] = not integration.is_built_in + # Include version for custom integrations + if not integration.is_built_in and integration.version: + device_info["custom_integration_version"] = str(integration.version) 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/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 0e14d556620..1ade8eed37e 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -975,6 +975,7 @@ async def test_submitting_legacy_integrations( assert snapshot == submitted_data +@pytest.mark.usefixtures("enable_custom_integrations") async def test_devices_payload( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -984,14 +985,16 @@ async def 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 + mock_custom_config_entry = MockConfigEntry(domain="test") + mock_custom_config_entry.add_to_hass(hass) + + # Normal device with all fields device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, identifiers={("device", "1")}, @@ -1005,7 +1008,7 @@ async def test_devices_payload( configuration_url="http://example.com/config", ) - # Ignored because service type + # Service type device device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, identifiers={("device", "2")}, @@ -1014,7 +1017,7 @@ async def test_devices_payload( entry_type=dr.DeviceEntryType.SERVICE, ) - # Ignored because no model id + # Device without 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( @@ -1023,14 +1026,14 @@ async def test_devices_payload( manufacturer="test-manufacturer", ) - # Ignored because no manufacturer + # Device without 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 with via_device reference device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, identifiers={("device", "6")}, @@ -1039,9 +1042,16 @@ async def test_devices_payload( via_device=("device", "1"), ) + # Device from custom integration + device_registry.async_get_or_create( + config_entry_id=mock_custom_config_entry.entry_id, + identifiers={("device", "7")}, + manufacturer="test-manufacturer7", + model_id="test-model-id7", + ) + assert await async_devices_payload(hass) == { "version": "home-assistant:1", - "no_model_id": [], "devices": [ { "manufacturer": "test-manufacturer", @@ -1053,6 +1063,42 @@ async def test_devices_payload( "is_custom_integration": False, "has_configuration_url": True, "via_device": None, + "entry_type": None, + }, + { + "manufacturer": "test-manufacturer", + "model_id": "test-model-id", + "model": None, + "sw_version": None, + "hw_version": None, + "integration": "hue", + "is_custom_integration": False, + "has_configuration_url": False, + "via_device": None, + "entry_type": "service", + }, + { + "manufacturer": "test-manufacturer", + "model_id": None, + "model": None, + "sw_version": None, + "hw_version": None, + "integration": "no_model_id", + "has_configuration_url": False, + "via_device": None, + "entry_type": None, + }, + { + "manufacturer": None, + "model_id": "test-model-id", + "model": None, + "sw_version": None, + "hw_version": None, + "integration": "hue", + "is_custom_integration": False, + "has_configuration_url": False, + "via_device": None, + "entry_type": None, }, { "manufacturer": "test-manufacturer6", @@ -1064,6 +1110,20 @@ async def test_devices_payload( "is_custom_integration": False, "has_configuration_url": False, "via_device": 0, + "entry_type": None, + }, + { + "entry_type": None, + "has_configuration_url": False, + "hw_version": None, + "integration": "test", + "manufacturer": "test-manufacturer7", + "model": None, + "model_id": "test-model-id7", + "sw_version": None, + "via_device": None, + "is_custom_integration": True, + "custom_integration_version": "1.2.3", }, ], } From c9517287675bf7d3fd64302fc8fa3362969cfff2 Mon Sep 17 00:00:00 2001 From: Jamin Date: Sun, 17 Aug 2025 09:16:20 -0500 Subject: [PATCH 1079/1113] VOIP RTP cleanup (#150490) --- homeassistant/components/voip/assist_satellite.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index ac8065cabf7..8d11cf2ff89 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -364,6 +364,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol if self._check_hangup_task is not None: self._check_hangup_task.cancel() self._check_hangup_task = None + self._rtp_port = None def connection_made(self, transport): """Server is ready.""" From 27ac375183a1a73960f76a8defd591bd07a8e352 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sun, 17 Aug 2025 16:21:28 +0200 Subject: [PATCH 1080/1113] Remove unused strings in modbus (#150795) --- homeassistant/components/modbus/strings.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 0749ba4a2c8..dd71785740b 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -70,14 +70,6 @@ } }, "issues": { - "removed_lazy_error_count": { - "title": "{config_key} configuration key is being removed", - "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue. All errors will be reported, as lazy_error_count is accepted but ignored" - }, - "deprecated_retries": { - "title": "{config_key} configuration key is being removed", - "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue.\n\nThe maximum number of retries is now fixed to 3." - }, "missing_modbus_name": { "title": "Modbus entry with host {sub_2} missing name", "description": "Please add `{sub_1}` key to the {integration} entry with host `{sub_2}` in your configuration.yaml file and restart Home Assistant to fix this issue\n\n. `{sub_1}: {sub_3}` have been added." From f03955b773db0b14bcf984ce21402ce9a126b95f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 17 Aug 2025 16:56:25 +0200 Subject: [PATCH 1081/1113] NextDNS tests improvements (#150791) --- tests/components/nextdns/__init__.py | 20 +--- tests/components/nextdns/conftest.py | 32 ++++++ .../components/nextdns/test_binary_sensor.py | 55 ++++++---- tests/components/nextdns/test_button.py | 39 ++++--- tests/components/nextdns/test_config_flow.py | 100 +++++++++++++----- tests/components/nextdns/test_coordinator.py | 11 +- tests/components/nextdns/test_diagnostics.py | 10 +- tests/components/nextdns/test_init.py | 57 +++++----- tests/components/nextdns/test_sensor.py | 98 ++++------------- tests/components/nextdns/test_switch.py | 79 ++++++++------ 10 files changed, 275 insertions(+), 226 deletions(-) create mode 100644 tests/components/nextdns/conftest.py diff --git a/tests/components/nextdns/__init__.py b/tests/components/nextdns/__init__.py index 1fa0d234196..ef46eecaa66 100644 --- a/tests/components/nextdns/__init__.py +++ b/tests/components/nextdns/__init__.py @@ -13,8 +13,6 @@ from nextdns import ( Settings, ) -from homeassistant.components.nextdns.const import CONF_PROFILE_ID, DOMAIN -from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -155,20 +153,12 @@ def mock_nextdns(): yield -async def init_integration(hass: HomeAssistant) -> MockConfigEntry: +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Set up the NextDNS integration in Home Assistant.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="Fake Profile", - unique_id="xyz12", - data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "xyz12"}, - entry_id="d9aa37407ddac7b964a99e86312288d6", - ) - - entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) with mock_nextdns(): - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - - return entry diff --git a/tests/components/nextdns/conftest.py b/tests/components/nextdns/conftest.py new file mode 100644 index 00000000000..b46c51d673c --- /dev/null +++ b/tests/components/nextdns/conftest.py @@ -0,0 +1,32 @@ +"""Common fixtures for the NextDNS tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.nextdns.const import CONF_PROFILE_ID, DOMAIN +from homeassistant.const import CONF_API_KEY + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nextdns.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Fake Profile", + unique_id="xyz12", + data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "xyz12"}, + entry_id="d9aa37407ddac7b964a99e86312288d6", + ) diff --git a/tests/components/nextdns/test_binary_sensor.py b/tests/components/nextdns/test_binary_sensor.py index 99e40af0dce..c9ad0d6e209 100644 --- a/tests/components/nextdns/test_binary_sensor.py +++ b/tests/components/nextdns/test_binary_sensor.py @@ -3,56 +3,65 @@ from datetime import timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from nextdns import ApiError from syrupy.assertion import SnapshotAssertion -from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util.dt import utcnow from . import init_integration, mock_nextdns -from tests.common import async_fire_time_changed, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform async def test_binary_sensor( - hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, ) -> None: """Test states of the binary sensors.""" with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.BINARY_SENSOR]): - entry = await init_integration(hass) + await init_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) -async def test_availability(hass: HomeAssistant) -> None: +async def test_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: """Ensure that we mark the entities unavailable correctly when service causes an error.""" - await init_integration(hass) + with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.BINARY_SENSOR]): + await init_integration(hass, mock_config_entry) - state = hass.states.get("binary_sensor.fake_profile_device_connection_status") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == STATE_ON + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + entity_ids = (entry.entity_id for entry in entity_entries) - future = utcnow() + timedelta(minutes=10) + for entity_id in entity_ids: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + freezer.tick(timedelta(minutes=10)) with patch( "homeassistant.components.nextdns.NextDns.connection_status", side_effect=ApiError("API Error"), ): - async_fire_time_changed(hass, future) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("binary_sensor.fake_profile_device_connection_status") - assert state - assert state.state == STATE_UNAVAILABLE + for entity_id in entity_ids: + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - future = utcnow() + timedelta(minutes=20) + freezer.tick(timedelta(minutes=10)) with mock_nextdns(): - async_fire_time_changed(hass, future) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("binary_sensor.fake_profile_device_connection_status") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == STATE_ON + for entity_id in entity_ids: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE diff --git a/tests/components/nextdns/test_button.py b/tests/components/nextdns/test_button.py index 0cb4a7cd0df..03108e81984 100644 --- a/tests/components/nextdns/test_button.py +++ b/tests/components/nextdns/test_button.py @@ -15,31 +15,34 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt as dt_util from . import init_integration -from tests.common import snapshot_platform +from tests.common import MockConfigEntry, snapshot_platform async def test_button( - hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, ) -> None: """Test states of the button.""" with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.BUTTON]): - entry = await init_integration(hass) + await init_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) -async def test_button_press(hass: HomeAssistant) -> None: +@pytest.mark.freeze_time("2023-10-21") +async def test_button_press( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test button press.""" - await init_integration(hass) + await init_integration(hass, mock_config_entry) - now = dt_util.utcnow() with ( patch("homeassistant.components.nextdns.NextDns.clear_logs") as mock_clear_logs, - patch("homeassistant.core.dt_util.utcnow", return_value=now), ): await hass.services.async_call( BUTTON_DOMAIN, @@ -53,7 +56,7 @@ async def test_button_press(hass: HomeAssistant) -> None: state = hass.states.get("button.fake_profile_clear_logs") assert state - assert state.state == now.isoformat() + assert state.state == "2023-10-21T00:00:00+00:00" @pytest.mark.parametrize( @@ -65,9 +68,11 @@ async def test_button_press(hass: HomeAssistant) -> None: ClientError, ], ) -async def test_button_failure(hass: HomeAssistant, exc: Exception) -> None: +async def test_button_failure( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, exc: Exception +) -> None: """Tests that the press action throws HomeAssistantError.""" - await init_integration(hass) + await init_integration(hass, mock_config_entry) with ( patch("homeassistant.components.nextdns.NextDns.clear_logs", side_effect=exc), @@ -84,9 +89,11 @@ async def test_button_failure(hass: HomeAssistant, exc: Exception) -> None: ) -async def test_button_auth_error(hass: HomeAssistant) -> None: +async def test_button_auth_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Tests that the press action starts re-auth flow.""" - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) with patch( "homeassistant.components.nextdns.NextDns.clear_logs", @@ -99,7 +106,7 @@ async def test_button_auth_error(hass: HomeAssistant) -> None: blocking=True, ) - assert entry.state is ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -110,4 +117,4 @@ async def test_button_auth_error(hass: HomeAssistant) -> None: assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH - assert flow["context"].get("entry_id") == entry.entry_id + assert flow["context"].get("entry_id") == mock_config_entry.entry_id diff --git a/tests/components/nextdns/test_config_flow.py b/tests/components/nextdns/test_config_flow.py index 27a6cf1e7e0..d577fb21845 100644 --- a/tests/components/nextdns/test_config_flow.py +++ b/tests/components/nextdns/test_config_flow.py @@ -1,6 +1,6 @@ """Define tests for the NextDNS config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from nextdns import ApiError, InvalidApiKeyError import pytest @@ -14,8 +14,12 @@ from homeassistant.data_entry_flow import FlowResultType from . import PROFILES, init_integration, mock_nextdns +from tests.common import MockConfigEntry -async def test_form_create_entry(hass: HomeAssistant) -> None: + +async def test_form_create_entry( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test that the user step works.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -24,14 +28,9 @@ async def test_form_create_entry(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - with ( - patch( - "homeassistant.components.nextdns.NextDns.get_profiles", - return_value=PROFILES, - ), - patch( - "homeassistant.components.nextdns.async_setup_entry", return_value=True - ) as mock_setup_entry, + with patch( + "homeassistant.components.nextdns.NextDns.get_profiles", + return_value=PROFILES, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -44,12 +43,12 @@ async def test_form_create_entry(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_PROFILE_NAME: "Fake Profile"} ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Fake Profile" assert result["data"][CONF_API_KEY] == "fake_api_key" assert result["data"][CONF_PROFILE_ID] == "xyz12" + assert result["result"].unique_id == "xyz12" assert len(mock_setup_entry.mock_calls) == 1 @@ -64,24 +63,55 @@ async def test_form_create_entry(hass: HomeAssistant) -> None: ], ) async def test_form_errors( - hass: HomeAssistant, exc: Exception, base_error: str + hass: HomeAssistant, mock_setup_entry: AsyncMock, exc: Exception, base_error: str ) -> None: """Test we handle errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + with patch( "homeassistant.components.nextdns.NextDns.get_profiles", side_effect=exc ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_API_KEY: "fake_api_key"}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "fake_api_key"}, ) + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": base_error} + with patch( + "homeassistant.components.nextdns.NextDns.get_profiles", + return_value=PROFILES, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "fake_api_key"}, + ) -async def test_form_already_configured(hass: HomeAssistant) -> None: + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "profiles" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PROFILE_NAME: "Fake Profile"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Fake Profile" + assert result["data"][CONF_API_KEY] == "fake_api_key" + assert result["data"][CONF_PROFILE_ID] == "xyz12" + assert result["result"].unique_id == "xyz12" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test that errors are shown when duplicates are added.""" - await init_integration(hass) + await init_integration(hass, mock_config_entry) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -103,11 +133,13 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_reauth_successful(hass: HomeAssistant) -> None: +async def test_reauth_successful( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test starting a reauthentication flow.""" - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) - result = await entry.start_reauth_flow(hass) + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" @@ -122,7 +154,6 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: result["flow_id"], user_input={CONF_API_KEY: "new_api_key"}, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" @@ -139,12 +170,15 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: ], ) async def test_reauth_errors( - hass: HomeAssistant, exc: Exception, base_error: str + hass: HomeAssistant, + exc: Exception, + base_error: str, + mock_config_entry: MockConfigEntry, ) -> None: """Test reauthentication flow with errors.""" - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) - result = await entry.start_reauth_flow(hass) + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" @@ -155,6 +189,20 @@ async def test_reauth_errors( result["flow_id"], user_input={CONF_API_KEY: "new_api_key"}, ) - await hass.async_block_till_done() assert result["errors"] == {"base": base_error} + + with ( + patch( + "homeassistant.components.nextdns.NextDns.get_profiles", + return_value=PROFILES, + ), + mock_nextdns(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new_api_key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/nextdns/test_coordinator.py b/tests/components/nextdns/test_coordinator.py index f2b353ea2c5..83748f836b5 100644 --- a/tests/components/nextdns/test_coordinator.py +++ b/tests/components/nextdns/test_coordinator.py @@ -12,17 +12,18 @@ from homeassistant.core import HomeAssistant from . import init_integration -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed async def test_auth_error( hass: HomeAssistant, freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, ) -> None: """Test authentication error when polling data.""" - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) - assert entry.state is ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED freezer.tick(timedelta(minutes=10)) with ( @@ -62,7 +63,7 @@ async def test_auth_error( async_fire_time_changed(hass) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -73,4 +74,4 @@ async def test_auth_error( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH - assert flow["context"].get("entry_id") == entry.entry_id + assert flow["context"].get("entry_id") == mock_config_entry.entry_id diff --git a/tests/components/nextdns/test_diagnostics.py b/tests/components/nextdns/test_diagnostics.py index 4a5e09908ec..2b0c0564649 100644 --- a/tests/components/nextdns/test_diagnostics.py +++ b/tests/components/nextdns/test_diagnostics.py @@ -7,6 +7,7 @@ from homeassistant.core import HomeAssistant from . import init_integration +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -15,10 +16,11 @@ async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, ) -> None: """Test config entry diagnostics.""" - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) - assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot( - exclude=props("created_at", "modified_at") - ) + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) == snapshot(exclude=props("created_at", "modified_at")) diff --git a/tests/components/nextdns/test_init.py b/tests/components/nextdns/test_init.py index 0a0bf3fc487..217e75ca701 100644 --- a/tests/components/nextdns/test_init.py +++ b/tests/components/nextdns/test_init.py @@ -6,9 +6,9 @@ from nextdns import ApiError, InvalidApiKeyError import pytest from tenacity import RetryError -from homeassistant.components.nextdns.const import CONF_PROFILE_ID, DOMAIN +from homeassistant.components.nextdns.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import CONF_API_KEY, STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from . import init_integration @@ -16,9 +16,11 @@ from . import init_integration from tests.common import MockConfigEntry -async def test_async_setup_entry(hass: HomeAssistant) -> None: +async def test_async_setup_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test a successful setup entry.""" - await init_integration(hass) + await init_integration(hass, mock_config_entry) state = hass.states.get("sensor.fake_profile_dns_queries_blocked_ratio") assert state is not None @@ -29,55 +31,48 @@ async def test_async_setup_entry(hass: HomeAssistant) -> None: @pytest.mark.parametrize( "exc", [ApiError("API Error"), RetryError("Retry Error"), TimeoutError] ) -async def test_config_not_ready(hass: HomeAssistant, exc: Exception) -> None: +async def test_config_not_ready( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, exc: Exception +) -> None: """Test for setup failure if the connection to the service fails.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="Fake Profile", - unique_id="xyz12", - data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "xyz12"}, - ) - with patch( "homeassistant.components.nextdns.NextDns.get_profiles", side_effect=exc, ): - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_unload_entry(hass: HomeAssistant) -> None: +async def test_unload_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test successful unload of entry.""" - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state is ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED - assert await hass.config_entries.async_unload(entry.entry_id) + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.NOT_LOADED + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) -async def test_config_auth_failed(hass: HomeAssistant) -> None: +async def test_config_auth_failed( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test for setup failure if the auth fails.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="Fake Profile", - unique_id="xyz12", - data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "xyz12"}, - ) - entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) with patch( "homeassistant.components.nextdns.NextDns.get_profiles", side_effect=InvalidApiKeyError, ): - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(mock_config_entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_ERROR + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -88,4 +83,4 @@ async def test_config_auth_failed(hass: HomeAssistant) -> None: assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH - assert flow["context"].get("entry_id") == entry.entry_id + assert flow["context"].get("entry_id") == mock_config_entry.entry_id diff --git a/tests/components/nextdns/test_sensor.py b/tests/components/nextdns/test_sensor.py index 43e823fbf38..3ef1ab55f9f 100644 --- a/tests/components/nextdns/test_sensor.py +++ b/tests/components/nextdns/test_sensor.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from nextdns import ApiError import pytest from syrupy.assertion import SnapshotAssertion @@ -10,11 +11,10 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util.dt import utcnow from . import init_integration, mock_nextdns -from tests.common import async_fire_time_changed, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -22,48 +22,35 @@ async def test_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, ) -> None: """Test states of sensors.""" with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.SENSOR]): - entry = await init_integration(hass) + await init_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.usefixtures("entity_registry_enabled_by_default") async def test_availability( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Ensure that we mark the entities unavailable correctly when service causes an error.""" - await init_integration(hass) + with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.SENSOR]): + await init_integration(hass, mock_config_entry) - state = hass.states.get("sensor.fake_profile_dns_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "100" + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + entity_ids = (entry.entity_id for entry in entity_entries) - state = hass.states.get("sensor.fake_profile_dns_over_https_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "20" + for entity_id in entity_ids: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE - state = hass.states.get("sensor.fake_profile_dnssec_validated_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "75" - - state = hass.states.get("sensor.fake_profile_encrypted_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "60" - - state = hass.states.get("sensor.fake_profile_ipv4_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "90" - - future = utcnow() + timedelta(minutes=10) + freezer.tick(timedelta(minutes=10)) with ( patch( "homeassistant.components.nextdns.NextDns.get_analytics_status", @@ -86,55 +73,16 @@ async def test_availability( side_effect=ApiError("API Error"), ), ): - async_fire_time_changed(hass, future) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("sensor.fake_profile_dns_queries") - assert state - assert state.state == STATE_UNAVAILABLE + for entity_id in entity_ids: + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - state = hass.states.get("sensor.fake_profile_dns_over_https_queries") - assert state - assert state.state == STATE_UNAVAILABLE - - state = hass.states.get("sensor.fake_profile_dnssec_validated_queries") - assert state - assert state.state == STATE_UNAVAILABLE - - state = hass.states.get("sensor.fake_profile_encrypted_queries") - assert state - assert state.state == STATE_UNAVAILABLE - - state = hass.states.get("sensor.fake_profile_ipv4_queries") - assert state - assert state.state == STATE_UNAVAILABLE - - future = utcnow() + timedelta(minutes=20) + freezer.tick(timedelta(minutes=10)) with mock_nextdns(): - async_fire_time_changed(hass, future) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("sensor.fake_profile_dns_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "100" - - state = hass.states.get("sensor.fake_profile_dns_over_https_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "20" - - state = hass.states.get("sensor.fake_profile_dnssec_validated_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "75" - - state = hass.states.get("sensor.fake_profile_encrypted_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "60" - - state = hass.states.get("sensor.fake_profile_ipv4_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "90" + for entity_id in entity_ids: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE diff --git a/tests/components/nextdns/test_switch.py b/tests/components/nextdns/test_switch.py index 1b0edb2c83c..645ca11ac49 100644 --- a/tests/components/nextdns/test_switch.py +++ b/tests/components/nextdns/test_switch.py @@ -5,6 +5,7 @@ from unittest.mock import Mock, patch from aiohttp import ClientError from aiohttp.client_exceptions import ClientConnectorError +from freezegun.api import FrozenDateTimeFactory from nextdns import ApiError, InvalidApiKeyError import pytest from syrupy.assertion import SnapshotAssertion @@ -25,11 +26,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.util.dt import utcnow from . import init_integration, mock_nextdns -from tests.common import async_fire_time_changed, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -37,17 +37,20 @@ async def test_switch( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, ) -> None: """Test states of the switches.""" with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.SWITCH]): - entry = await init_integration(hass) + await init_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) -async def test_switch_on(hass: HomeAssistant) -> None: +async def test_switch_on( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test the switch can be turned on.""" - await init_integration(hass) + await init_integration(hass, mock_config_entry) state = hass.states.get("switch.fake_profile_block_page") assert state @@ -71,9 +74,11 @@ async def test_switch_on(hass: HomeAssistant) -> None: mock_switch_on.assert_called_once() -async def test_switch_off(hass: HomeAssistant) -> None: +async def test_switch_off( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test the switch can be turned on.""" - await init_integration(hass) + await init_integration(hass, mock_config_entry) state = hass.states.get("switch.fake_profile_web3") assert state @@ -97,6 +102,7 @@ async def test_switch_off(hass: HomeAssistant) -> None: mock_switch_on.assert_called_once() +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( "exc", [ @@ -105,36 +111,43 @@ async def test_switch_off(hass: HomeAssistant) -> None: TimeoutError, ], ) -async def test_availability(hass: HomeAssistant, exc: Exception) -> None: +async def test_availability( + hass: HomeAssistant, + exc: Exception, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: """Ensure that we mark the entities unavailable correctly when service causes an error.""" - await init_integration(hass) + with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.SWITCH]): + await init_integration(hass, mock_config_entry) - state = hass.states.get("switch.fake_profile_web3") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == STATE_ON + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + entity_ids = (entry.entity_id for entry in entity_entries) - future = utcnow() + timedelta(minutes=10) + for entity_id in entity_ids: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + freezer.tick(timedelta(minutes=10)) with patch( "homeassistant.components.nextdns.NextDns.get_settings", side_effect=exc, ): - async_fire_time_changed(hass, future) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("switch.fake_profile_web3") - assert state - assert state.state == STATE_UNAVAILABLE + for entity_id in entity_ids: + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - future = utcnow() + timedelta(minutes=20) + freezer.tick(timedelta(minutes=10)) with mock_nextdns(): - async_fire_time_changed(hass, future) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("switch.fake_profile_web3") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == STATE_ON + for entity_id in entity_ids: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE @pytest.mark.parametrize( @@ -146,9 +159,11 @@ async def test_availability(hass: HomeAssistant, exc: Exception) -> None: ClientError, ], ) -async def test_switch_failure(hass: HomeAssistant, exc: Exception) -> None: +async def test_switch_failure( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, exc: Exception +) -> None: """Tests that the turn on/off service throws HomeAssistantError.""" - await init_integration(hass) + await init_integration(hass, mock_config_entry) with ( patch("homeassistant.components.nextdns.NextDns.set_setting", side_effect=exc), @@ -162,9 +177,11 @@ async def test_switch_failure(hass: HomeAssistant, exc: Exception) -> None: ) -async def test_switch_auth_error(hass: HomeAssistant) -> None: +async def test_switch_auth_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Tests that the turn on/off action starts re-auth flow.""" - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) with patch( "homeassistant.components.nextdns.NextDns.set_setting", @@ -177,7 +194,7 @@ async def test_switch_auth_error(hass: HomeAssistant) -> None: blocking=True, ) - assert entry.state is ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -188,4 +205,4 @@ async def test_switch_auth_error(hass: HomeAssistant) -> None: assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH - assert flow["context"].get("entry_id") == entry.entry_id + assert flow["context"].get("entry_id") == mock_config_entry.entry_id From 942274234e17daf9500d86e5eedba9a5e1d1c348 Mon Sep 17 00:00:00 2001 From: Yevhenii Vaskivskyi Date: Sun, 17 Aug 2025 16:59:02 +0200 Subject: [PATCH 1082/1113] Add asusrouter logger definition to asuswrt (#150747) --- homeassistant/components/asuswrt/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json index 4b6f2e40283..36ab9801bca 100644 --- a/homeassistant/components/asuswrt/manifest.json +++ b/homeassistant/components/asuswrt/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/asuswrt", "integration_type": "hub", "iot_class": "local_polling", - "loggers": ["aioasuswrt", "asyncssh"], + "loggers": ["aioasuswrt", "asusrouter", "asyncssh"], "requirements": ["aioasuswrt==1.4.0", "asusrouter==1.19.0"] } From 1f43f82ea619cdfa3ad3960c33c86ef38e8a3095 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Sun, 17 Aug 2025 16:03:46 +0100 Subject: [PATCH 1083/1113] Update systembridgeconnector to 4.1.10 (#150736) --- homeassistant/components/system_bridge/manifest.json | 2 +- requirements_all.txt | 5 +---- requirements_test_all.txt | 5 +---- script/hassfest/requirements.py | 5 ----- 4 files changed, 3 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index 2799cf31fdd..c19f36f14dd 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -9,6 +9,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["systembridgeconnector"], - "requirements": ["systembridgeconnector==4.1.5", "systembridgemodels==4.2.4"], + "requirements": ["systembridgeconnector==4.1.10"], "zeroconf": ["_system-bridge._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 44348c7b07c..24901b2fa68 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2873,10 +2873,7 @@ switchbot-api==2.7.0 synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridgeconnector==4.1.5 - -# homeassistant.components.system_bridge -systembridgemodels==4.2.4 +systembridgeconnector==4.1.10 # homeassistant.components.tailscale tailscale==0.6.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d93a9928d64..9bddaa344ad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2371,10 +2371,7 @@ surepy==0.9.0 switchbot-api==2.7.0 # homeassistant.components.system_bridge -systembridgeconnector==4.1.5 - -# homeassistant.components.system_bridge -systembridgemodels==4.2.4 +systembridgeconnector==4.1.10 # homeassistant.components.tailscale tailscale==0.6.2 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 30369dd163a..d8aa383cfec 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -271,11 +271,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { "squeezebox": {"pysqueezebox": {"async-timeout"}}, "ssdp": {"async-upnp-client": {"async-timeout"}}, "surepetcare": {"surepy": {"async-timeout"}}, - "system_bridge": { - # https://github.com/timmo001/system-bridge-connector/pull/78 - # systembridgeconnector > incremental > setuptools - "incremental": {"setuptools"} - }, "travisci": { # https://github.com/menegazzo/travispy seems to be unmaintained # and unused https://www.home-assistant.io/integrations/travisci From 6f6f5809d0ea9237c3c8c2d4c491107abd21c211 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sun, 17 Aug 2025 16:07:23 +0100 Subject: [PATCH 1084/1113] Fix volume step error in Squeezebox media player (#150760) --- homeassistant/components/squeezebox/media_player.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 839e419dd96..a857602a584 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -326,7 +326,7 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" if self._player.volume is not None: - return int(float(self._player.volume)) / 100.0 + return float(self._player.volume) / 100.0 return None @@ -435,7 +435,7 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" - volume_percent = str(int(volume * 100)) + volume_percent = str(round(volume * 100)) await self._player.async_set_volume(volume_percent) await self.coordinator.async_refresh() From db1707fd72bd06d75a066d7a671bee9bba9289c6 Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Sun, 17 Aug 2025 08:08:25 -0700 Subject: [PATCH 1085/1113] Mark `config-flow-test-coverage` as `done` in APCUPSD quality scale (#150733) --- homeassistant/components/apcupsd/quality_scale.yaml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/apcupsd/quality_scale.yaml b/homeassistant/components/apcupsd/quality_scale.yaml index 6c71cb16b5d..316f3e97bbe 100644 --- a/homeassistant/components/apcupsd/quality_scale.yaml +++ b/homeassistant/components/apcupsd/quality_scale.yaml @@ -7,10 +7,7 @@ rules: 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. + config-flow-test-coverage: done config-flow: done dependency-transparency: done docs-actions: From b222cc5889901a2c36d5fe5af45f342cbea0c39e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 17 Aug 2025 17:08:35 +0200 Subject: [PATCH 1086/1113] Use lifecycle hook instead of storing callback in starline (#150707) --- homeassistant/components/starline/entity.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/homeassistant/components/starline/entity.py b/homeassistant/components/starline/entity.py index f8846c2a97f..f940971c15c 100644 --- a/homeassistant/components/starline/entity.py +++ b/homeassistant/components/starline/entity.py @@ -2,8 +2,6 @@ from __future__ import annotations -from collections.abc import Callable - from homeassistant.helpers.entity import Entity from .account import StarlineAccount, StarlineDevice @@ -24,7 +22,6 @@ class StarlineEntity(Entity): self._key = key self._attr_unique_id = f"starline-{key}-{device.device_id}" self._attr_device_info = account.device_info(device) - self._unsubscribe_api: Callable | None = None @property def available(self) -> bool: @@ -38,11 +35,4 @@ class StarlineEntity(Entity): async def async_added_to_hass(self) -> None: """Call when entity about to be added to Home Assistant.""" await super().async_added_to_hass() - self._unsubscribe_api = self._account.api.add_update_listener(self.update) - - async def async_will_remove_from_hass(self) -> None: - """Call when entity is being removed from Home Assistant.""" - await super().async_will_remove_from_hass() - if self._unsubscribe_api is not None: - self._unsubscribe_api() - self._unsubscribe_api = None + self.async_on_remove(self._account.api.add_update_listener(self.update)) From ff418f513a8402f578bb0d7db0a9389c7a15d463 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Sun, 17 Aug 2025 11:15:29 -0400 Subject: [PATCH 1087/1113] Add dialog mode select for Sonos Arc Ultra soundbar (#150637) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/sonos/const.py | 5 + homeassistant/components/sonos/select.py | 129 +++++++++++++ homeassistant/components/sonos/speaker.py | 8 + homeassistant/components/sonos/strings.json | 12 ++ tests/components/sonos/test_select.py | 189 ++++++++++++++++++++ 5 files changed, 343 insertions(+) create mode 100644 homeassistant/components/sonos/select.py create mode 100644 tests/components/sonos/test_select.py diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 440d9a3aea7..ac2e3f50f13 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -15,6 +15,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.MEDIA_PLAYER, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] @@ -154,6 +155,7 @@ SONOS_CREATE_AUDIO_FORMAT_SENSOR = "sonos_create_audio_format_sensor" SONOS_CREATE_BATTERY = "sonos_create_battery" SONOS_CREATE_FAVORITES_SENSOR = "sonos_create_favorites_sensor" SONOS_CREATE_MIC_SENSOR = "sonos_create_mic_sensor" +SONOS_CREATE_SELECTS = "sonos_create_selects" SONOS_CREATE_SWITCHES = "sonos_create_switches" SONOS_CREATE_LEVELS = "sonos_create_levels" SONOS_CREATE_MEDIA_PLAYER = "sonos_create_media_player" @@ -189,6 +191,9 @@ MODELS_LINEIN_AND_TV = ("AMP",) MODEL_SONOS_ARC_ULTRA = "SONOS ARC ULTRA" ATTR_SPEECH_ENHANCEMENT_ENABLED = "speech_enhance_enabled" +SPEECH_DIALOG_LEVEL = "speech_dialog_level" +ATTR_DIALOG_LEVEL = "dialog_level" +ATTR_DIALOG_LEVEL_ENUM = "dialog_level_enum" AVAILABILITY_CHECK_INTERVAL = datetime.timedelta(minutes=1) AVAILABILITY_TIMEOUT = AVAILABILITY_CHECK_INTERVAL.total_seconds() * 4.5 diff --git a/homeassistant/components/sonos/select.py b/homeassistant/components/sonos/select.py new file mode 100644 index 00000000000..052a1d87967 --- /dev/null +++ b/homeassistant/components/sonos/select.py @@ -0,0 +1,129 @@ +"""Select entities for Sonos.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + ATTR_DIALOG_LEVEL, + ATTR_DIALOG_LEVEL_ENUM, + MODEL_SONOS_ARC_ULTRA, + SONOS_CREATE_SELECTS, + SPEECH_DIALOG_LEVEL, +) +from .entity import SonosEntity +from .helpers import SonosConfigEntry, soco_error +from .speaker import SonosSpeaker + + +@dataclass(frozen=True, kw_only=True) +class SonosSelectEntityDescription(SelectEntityDescription): + """Describes AirGradient select entity.""" + + soco_attribute: str + speaker_attribute: str + speaker_model: str + + +SELECT_TYPES: list[SonosSelectEntityDescription] = [ + SonosSelectEntityDescription( + key=SPEECH_DIALOG_LEVEL, + translation_key=SPEECH_DIALOG_LEVEL, + soco_attribute=ATTR_DIALOG_LEVEL, + speaker_attribute=ATTR_DIALOG_LEVEL_ENUM, + speaker_model=MODEL_SONOS_ARC_ULTRA, + options=["off", "low", "medium", "high", "max"], + ), +] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SonosConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Sonos select platform from a config entry.""" + + def available_soco_attributes( + speaker: SonosSpeaker, + ) -> list[SonosSelectEntityDescription]: + features: list[SonosSelectEntityDescription] = [] + for select_data in SELECT_TYPES: + if select_data.speaker_model == speaker.model_name.upper(): + if ( + state := getattr(speaker.soco, select_data.soco_attribute, None) + ) is not None: + setattr(speaker, select_data.speaker_attribute, state) + features.append(select_data) + return features + + async def _async_create_entities(speaker: SonosSpeaker) -> None: + available_features = await hass.async_add_executor_job( + available_soco_attributes, speaker + ) + async_add_entities( + SonosSelectEntity(speaker, config_entry, select_data) + for select_data in available_features + ) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, SONOS_CREATE_SELECTS, _async_create_entities) + ) + + +class SonosSelectEntity(SonosEntity, SelectEntity): + """Representation of a Sonos select entity.""" + + def __init__( + self, + speaker: SonosSpeaker, + config_entry: SonosConfigEntry, + select_data: SonosSelectEntityDescription, + ) -> None: + """Initialize the select entity.""" + super().__init__(speaker, config_entry) + self._attr_unique_id = f"{self.soco.uid}-{select_data.key}" + self._attr_translation_key = select_data.translation_key + assert select_data.options is not None + self._attr_options = select_data.options + self.speaker_attribute = select_data.speaker_attribute + self.soco_attribute = select_data.soco_attribute + + async def _async_fallback_poll(self) -> None: + """Poll the value if subscriptions are not working.""" + await self.hass.async_add_executor_job(self.poll_state) + self.async_write_ha_state() + + @soco_error() + def poll_state(self) -> None: + """Poll the device for the current state.""" + state = getattr(self.soco, self.soco_attribute) + setattr(self.speaker, self.speaker_attribute, state) + + @property + def current_option(self) -> str | None: + """Return the current option for the entity.""" + option = getattr(self.speaker, self.speaker_attribute, None) + if not isinstance(option, int) or not (0 <= option < len(self._attr_options)): + _LOGGER.error( + "Invalid option %s for %s on %s", + option, + self.soco_attribute, + self.speaker.zone_name, + ) + return None + return self._attr_options[option] + + @soco_error() + def select_option(self, option: str) -> None: + """Set a new value.""" + dialog_level = self._attr_options.index(option) + setattr(self.soco, self.soco_attribute, dialog_level) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 894d32fcb97..427f02f0479 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_DIALOG_LEVEL, ATTR_SPEECH_ENHANCEMENT_ENABLED, AVAILABILITY_TIMEOUT, BATTERY_SCAN_INTERVAL, @@ -47,6 +48,7 @@ from .const import ( SONOS_CREATE_LEVELS, SONOS_CREATE_MEDIA_PLAYER, SONOS_CREATE_MIC_SENSOR, + SONOS_CREATE_SELECTS, SONOS_CREATE_SWITCHES, SONOS_FALLBACK_POLL, SONOS_REBOOTED, @@ -158,6 +160,7 @@ class SonosSpeaker: # Home theater self.audio_delay: int | None = None self.dialog_level: bool | None = None + self.dialog_level_enum: int | None = None self.speech_enhance_enabled: bool | None = None self.night_mode: bool | None = None self.sub_enabled: bool | None = None @@ -253,6 +256,7 @@ class SonosSpeaker: ]: dispatches.append((SONOS_CREATE_ALARM, self, new_alarms)) + dispatches.append((SONOS_CREATE_SELECTS, self)) dispatches.append((SONOS_CREATE_SWITCHES, self)) dispatches.append((SONOS_CREATE_MEDIA_PLAYER, self)) dispatches.append((SONOS_SPEAKER_ADDED, self.soco.uid)) @@ -593,6 +597,10 @@ class SonosSpeaker: if int_var in variables: setattr(self, int_var, variables[int_var]) + for enum_var in (ATTR_DIALOG_LEVEL,): + if enum_var in variables: + setattr(self, f"{enum_var}_enum", variables[enum_var]) + self.async_write_entity_states() # diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index b2f20449beb..068290066b7 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -50,6 +50,18 @@ "name": "Music surround level" } }, + "select": { + "speech_dialog_level": { + "name": "Dialog level", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "max": "Max" + } + } + }, "sensor": { "audio_input_format": { "name": "Audio input format" diff --git a/tests/components/sonos/test_select.py b/tests/components/sonos/test_select.py new file mode 100644 index 00000000000..e573db5275c --- /dev/null +++ b/tests/components/sonos/test_select.py @@ -0,0 +1,189 @@ +"""Tests for the Sonos select platform.""" + +import logging +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.sonos.const import ( + ATTR_DIALOG_LEVEL, + MODEL_SONOS_ARC_ULTRA, + SCAN_INTERVAL, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import create_rendering_control_event + +from tests.common import async_fire_time_changed + +SELECT_DIALOG_LEVEL_ENTITY = "select.zone_a_dialog_level" + + +@pytest.fixture(name="platform_select", autouse=True) +async def platform_binary_sensor_fixture(): + """Patch Sonos to only load select platform.""" + with patch("homeassistant.components.sonos.PLATFORMS", [Platform.SELECT]): + yield + + +@pytest.mark.parametrize( + ("level", "result"), + [ + (0, "off"), + (1, "low"), + (2, "medium"), + (3, "high"), + (4, "max"), + ], +) +async def test_select_dialog_level( + hass: HomeAssistant, + async_setup_sonos, + soco, + entity_registry: er.EntityRegistry, + speaker_info: dict[str, str], + level: int, + result: str, +) -> None: + """Test dialog level select entity.""" + + speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() + soco.get_speaker_info.return_value = speaker_info + soco.dialog_level = level + + await async_setup_sonos() + + dialog_level_select = entity_registry.entities[SELECT_DIALOG_LEVEL_ENTITY] + dialog_level_state = hass.states.get(dialog_level_select.entity_id) + assert dialog_level_state.state == result + + +async def test_select_dialog_invalid_level( + hass: HomeAssistant, + async_setup_sonos, + soco, + entity_registry: er.EntityRegistry, + speaker_info: dict[str, str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test receiving an invalid level from the speaker.""" + + speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() + soco.get_speaker_info.return_value = speaker_info + soco.dialog_level = 10 + + with caplog.at_level(logging.WARNING): + await async_setup_sonos() + assert "Invalid option 10 for dialog_level" in caplog.text + + dialog_level_select = entity_registry.entities[SELECT_DIALOG_LEVEL_ENTITY] + dialog_level_state = hass.states.get(dialog_level_select.entity_id) + assert dialog_level_state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("result", "option"), + [ + (0, "off"), + (1, "low"), + (2, "medium"), + (3, "high"), + (4, "max"), + ], +) +async def test_select_dialog_level_set( + hass: HomeAssistant, + async_setup_sonos, + soco, + speaker_info: dict[str, str], + result: int, + option: str, +) -> None: + """Test setting dialog level select entity.""" + + speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() + soco.get_speaker_info.return_value = speaker_info + soco.dialog_level = 0 + + await async_setup_sonos() + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: SELECT_DIALOG_LEVEL_ENTITY, ATTR_OPTION: option}, + blocking=True, + ) + + assert soco.dialog_level == result + + +async def test_select_dialog_level_only_arc_ultra( + hass: HomeAssistant, + async_setup_sonos, + entity_registry: er.EntityRegistry, + speaker_info: dict[str, str], +) -> None: + """Test the dialog level select is only created for Sonos Arc Ultra.""" + + speaker_info["model_name"] = "Sonos S1" + await async_setup_sonos() + + assert SELECT_DIALOG_LEVEL_ENTITY not in entity_registry.entities + + +async def test_select_dialog_level_event( + hass: HomeAssistant, + async_setup_sonos, + soco, + entity_registry: er.EntityRegistry, + speaker_info: dict[str, str], +) -> None: + """Test dialog level select entity updated by event.""" + + speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() + soco.get_speaker_info.return_value = speaker_info + soco.dialog_level = 0 + + await async_setup_sonos() + + event = create_rendering_control_event(soco) + event.variables[ATTR_DIALOG_LEVEL] = 3 + soco.renderingControl.subscribe.return_value._callback(event) + await hass.async_block_till_done(wait_background_tasks=True) + + dialog_level_select = entity_registry.entities[SELECT_DIALOG_LEVEL_ENTITY] + dialog_level_state = hass.states.get(dialog_level_select.entity_id) + assert dialog_level_state.state == "high" + + +async def test_select_dialog_level_poll( + hass: HomeAssistant, + async_setup_sonos, + soco, + entity_registry: er.EntityRegistry, + speaker_info: dict[str, str], + freezer: FrozenDateTimeFactory, +) -> None: + """Test entity updated by poll when subscription fails.""" + + speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() + soco.get_speaker_info.return_value = speaker_info + soco.dialog_level = 0 + + await async_setup_sonos() + + soco.dialog_level = 4 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + dialog_level_select = entity_registry.entities[SELECT_DIALOG_LEVEL_ENTITY] + dialog_level_state = hass.states.get(dialog_level_select.entity_id) + assert dialog_level_state.state == "max" From e80c0909321bc56e8093715a80d1c687f29b3b42 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 17 Aug 2025 19:27:17 +0200 Subject: [PATCH 1088/1113] Pin gql to 3.5.3 (#150800) --- 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 7420766b910..e3c1bf4efe8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -220,3 +220,6 @@ num2words==0.5.14 # downgraded or upgraded by custom components # This ensures all use the same version pymodbus==3.11.1 + +# Some packages don't support gql 4.0.0 yet +gql<4.0.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 9b1f00385eb..9f65409b9be 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -246,6 +246,9 @@ num2words==0.5.14 # downgraded or upgraded by custom components # This ensures all use the same version pymodbus==3.11.1 + +# Some packages don't support gql 4.0.0 yet +gql<4.0.0 """ GENERATED_MESSAGE = ( From b44c47cd8017c8a6ce745b9a92d541188cb304e7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 17 Aug 2025 22:35:23 +0200 Subject: [PATCH 1089/1113] Removing myself as codeowner of Enphase (#150811) --- CODEOWNERS | 4 ++-- homeassistant/components/enphase_envoy/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 44c9d7d4547..c372cb70371 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -438,8 +438,8 @@ build.json @home-assistant/supervisor /tests/components/enigma2/ @autinerd /homeassistant/components/enocean/ @bdurrer /tests/components/enocean/ @bdurrer -/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @joostlek @catsmanac -/tests/components/enphase_envoy/ @bdraco @cgarwood @joostlek @catsmanac +/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac +/tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac /homeassistant/components/entur_public_transport/ @hfurubotten /homeassistant/components/environment_canada/ @gwww @michaeldavie /tests/components/environment_canada/ @gwww @michaeldavie diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index d9a3dd0bdce..0e1e89cf1e3 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -1,7 +1,7 @@ { "domain": "enphase_envoy", "name": "Enphase Envoy", - "codeowners": ["@bdraco", "@cgarwood", "@joostlek", "@catsmanac"], + "codeowners": ["@bdraco", "@cgarwood", "@catsmanac"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", From 9f17a8a943141281dbde6071b92514c6f27b1403 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Sun, 17 Aug 2025 16:47:45 -0400 Subject: [PATCH 1090/1113] Add tests and improve error handling for Sonos update_alarm service call (#150715) --- .../components/sonos/media_player.py | 9 ++- homeassistant/components/sonos/strings.json | 3 + tests/components/sonos/test_media_player.py | 70 +++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 6fb7bf00589..0b30c820da3 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -793,8 +793,13 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): if one_alarm.alarm_id == str(alarm_id): alarm = one_alarm if alarm is None: - _LOGGER.warning("Did not find alarm with id %s", alarm_id) - return + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_alarm_id", + translation_placeholders={ + "alarm_id": str(alarm_id), + }, + ) if time is not None: alarm.start_time = time if volume is not None: diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index 068290066b7..f8aee08f6b7 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -223,6 +223,9 @@ }, "timeout_join": { "message": "Timeout while waiting for Sonos player to join the group {group_description}" + }, + "invalid_alarm_id": { + "message": "Alarm {alarm_id} does not exist and cannot be updated." } } } diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 84ad624cdc8..41b18750fd4 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -33,14 +33,20 @@ from homeassistant.components.sonos.const import ( SOURCE_TV, ) from homeassistant.components.sonos.media_player import ( + ATTR_ALARM_ID, + ATTR_ENABLED, + ATTR_INCLUDE_LINKED_ZONES, + ATTR_VOLUME, LONG_SERVICE_TIMEOUT, SERVICE_GET_QUEUE, SERVICE_RESTORE, SERVICE_SNAPSHOT, + SERVICE_UPDATE_ALARM, VOLUME_INCREMENT, ) from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_TIME, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, @@ -1265,3 +1271,67 @@ async def test_media_source_list( """Test the mapping between the speaker model name and source_list.""" state = hass.states.get("media_player.zone_a") assert state.attributes.get(ATTR_INPUT_SOURCE_LIST) == source_list + + +async def test_service_update_alarm( + hass: HomeAssistant, + soco: MockSoCo, + async_autosetup_sonos, +) -> None: + """Test updating an alarm.""" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_ALARM, + { + ATTR_ENTITY_ID: "media_player.zone_a", + ATTR_ALARM_ID: 14, + ATTR_TIME: "07:15:00", + ATTR_VOLUME: 0.25, + ATTR_INCLUDE_LINKED_ZONES: True, + ATTR_ENABLED: True, + }, + blocking=True, + ) + + assert soco.alarmClock.UpdateAlarm.call_count == 1 + assert soco.alarmClock.UpdateAlarm.call_args.args[0] == [ + ("ID", "14"), + ("StartLocalTime", "07:15:00"), + ("Duration", "02:00:00"), + ("Recurrence", "DAILY"), + ("Enabled", "1"), + ("RoomUUID", "RINCON_test"), + ("ProgramURI", "x-rincon-buzzer:0"), + ("ProgramMetaData", ""), + ("PlayMode", "SHUFFLE_NOREPEAT"), + ("Volume", 25), + ("IncludeLinkedZones", "1"), + ] + + +async def test_service_update_alarm_dne( + hass: HomeAssistant, + soco: MockSoCo, + async_autosetup_sonos, +) -> None: + """Test updating an alarm that does not exist.""" + + with pytest.raises( + ServiceValidationError, + match="Alarm 99 does not exist and cannot be updated", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_ALARM, + { + ATTR_ENTITY_ID: "media_player.zone_a", + ATTR_ALARM_ID: 99, + ATTR_TIME: "07:15:00", + ATTR_VOLUME: 0.25, + ATTR_INCLUDE_LINKED_ZONES: True, + ATTR_ENABLED: True, + }, + blocking=True, + ) + assert soco.alarmClock.UpdateAlarm.call_count == 0 From 79bbae2fdeec9be30bc10d4ab6196a1590bb00e8 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Sun, 17 Aug 2025 17:41:51 -0400 Subject: [PATCH 1091/1113] Change the default name of the speech enhancement select for Sonos (#150815) --- homeassistant/components/sonos/strings.json | 2 +- tests/components/sonos/test_select.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index f8aee08f6b7..adb233519b2 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -52,7 +52,7 @@ }, "select": { "speech_dialog_level": { - "name": "Dialog level", + "name": "Speech enhancement", "state": { "off": "[%key:common::state::off%]", "low": "[%key:common::state::low%]", diff --git a/tests/components/sonos/test_select.py b/tests/components/sonos/test_select.py index e573db5275c..ada48de21f3 100644 --- a/tests/components/sonos/test_select.py +++ b/tests/components/sonos/test_select.py @@ -23,7 +23,7 @@ from .conftest import create_rendering_control_event from tests.common import async_fire_time_changed -SELECT_DIALOG_LEVEL_ENTITY = "select.zone_a_dialog_level" +SELECT_DIALOG_LEVEL_ENTITY = "select.zone_a_speech_enhancement" @pytest.fixture(name="platform_select", autouse=True) From 794deaa5fddc64182fa91aa46cdfb880ebe4d85c Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 17 Aug 2025 14:48:14 -0700 Subject: [PATCH 1092/1113] Bump opower to 0.15.2 (#150809) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index a10c5b2d15d..e127824ac19 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.15.1"] + "requirements": ["opower==0.15.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 24901b2fa68..b783b88fd0e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1625,7 +1625,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.15.1 +opower==0.15.2 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bddaa344ad..c6a84603533 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1381,7 +1381,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.15.1 +opower==0.15.2 # homeassistant.components.oralb oralb-ble==0.17.6 From 3ab4fd30354e3e4ffaf2e345427ab490d1591c66 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sun, 17 Aug 2025 23:48:53 +0200 Subject: [PATCH 1093/1113] Add number entity to togrill (#150609) --- homeassistant/components/togrill/__init__.py | 2 +- .../components/togrill/coordinator.py | 38 +- homeassistant/components/togrill/entity.py | 33 +- .../components/togrill/manifest.json | 1 + homeassistant/components/togrill/number.py | 138 +++++++ homeassistant/components/togrill/sensor.py | 4 +- homeassistant/components/togrill/strings.json | 19 + .../togrill/snapshots/test_number.ambr | 355 ++++++++++++++++++ tests/components/togrill/test_number.py | 243 ++++++++++++ 9 files changed, 825 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/togrill/number.py create mode 100644 tests/components/togrill/snapshots/test_number.ambr create mode 100644 tests/components/togrill/test_number.py diff --git a/homeassistant/components/togrill/__init__.py b/homeassistant/components/togrill/__init__.py index e938c56b9ee..fcacc851dd9 100644 --- a/homeassistant/components/togrill/__init__.py +++ b/homeassistant/components/togrill/__init__.py @@ -8,7 +8,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from .coordinator import DeviceNotFound, ToGrillConfigEntry, ToGrillCoordinator -_PLATFORMS: list[Platform] = [Platform.SENSOR] +_PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.NUMBER] async def async_setup_entry(hass: HomeAssistant, entry: ToGrillConfigEntry) -> bool: diff --git a/homeassistant/components/togrill/coordinator.py b/homeassistant/components/togrill/coordinator.py index 6aa06260178..b2f20963fc8 100644 --- a/homeassistant/components/togrill/coordinator.py +++ b/homeassistant/components/togrill/coordinator.py @@ -4,11 +4,17 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import TypeVar 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 togrill_bluetooth.packets import ( + Packet, + PacketA0Notify, + PacketA1Notify, + PacketA8Write, +) from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( @@ -25,11 +31,15 @@ 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 +from .const import CONF_PROBE_COUNT + type ToGrillConfigEntry = ConfigEntry[ToGrillCoordinator] SCAN_INTERVAL = timedelta(seconds=30) LOGGER = logging.getLogger(__name__) +PacketType = TypeVar("PacketType", bound=Packet) + def get_version_string(packet: PacketA0Notify) -> str: """Construct a version string from packet data.""" @@ -44,7 +54,7 @@ class DeviceFailed(UpdateFailed): """Update failed due to device disconnected.""" -class ToGrillCoordinator(DataUpdateCoordinator[dict[int, Packet]]): +class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Packet]]): """Class to manage fetching data.""" config_entry: ToGrillConfigEntry @@ -86,7 +96,12 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[int, Packet]]): if not device: raise DeviceNotFound("Unable to find device") - client = await Client.connect(device, self._notify_callback) + try: + client = await Client.connect(device, self._notify_callback) + except BleakError as exc: + self.logger.debug("Connection failed", exc_info=True) + raise DeviceNotFound("Unable to connect to device") from exc + try: packet_a0 = await client.read(PacketA0Notify) except (BleakError, DecodeError) as exc: @@ -123,16 +138,29 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[int, Packet]]): self.client = await self._connect_and_update_registry() return self.client + def get_packet( + self, packet_type: type[PacketType], probe=None + ) -> PacketType | None: + """Get a cached packet of a certain type.""" + + if packet := self.data.get((packet_type.type, probe)): + assert isinstance(packet, packet_type) + return packet + return None + def _notify_callback(self, packet: Packet): - self.data[packet.type] = packet + probe = getattr(packet, "probe", None) + self.data[(packet.type, probe)] = packet self.async_update_listeners() - async def _async_update_data(self) -> dict[int, Packet]: + async def _async_update_data(self) -> dict[tuple[int, int | None], Packet]: """Poll the device.""" client = await self._get_connected_client() try: await client.request(PacketA0Notify) await client.request(PacketA1Notify) + for probe in range(1, self.config_entry.data[CONF_PROBE_COUNT] + 1): + await client.write(PacketA8Write(probe=probe)) except BleakError as exc: raise DeviceFailed(f"Device failed {exc}") from exc return self.data diff --git a/homeassistant/components/togrill/entity.py b/homeassistant/components/togrill/entity.py index c1a254557c5..7d956ac2d57 100644 --- a/homeassistant/components/togrill/entity.py +++ b/homeassistant/components/togrill/entity.py @@ -2,9 +2,16 @@ from __future__ import annotations +from bleak.exc import BleakError +from togrill_bluetooth.client import Client +from togrill_bluetooth.exceptions import BaseError +from togrill_bluetooth.packets import PacketWrite + +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .coordinator import ToGrillCoordinator +from .const import DOMAIN +from .coordinator import LOGGER, ToGrillCoordinator class ToGrillEntity(CoordinatorEntity[ToGrillCoordinator]): @@ -16,3 +23,27 @@ class ToGrillEntity(CoordinatorEntity[ToGrillCoordinator]): """Initialize coordinator entity.""" super().__init__(coordinator) self._attr_device_info = coordinator.device_info + + def _get_client(self) -> Client: + client = self.coordinator.client + if client is None or not client.is_connected: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="disconnected" + ) + return client + + async def _write_packet(self, packet: PacketWrite) -> None: + client = self._get_client() + try: + await client.write(packet) + except BleakError as exc: + LOGGER.debug("Failed to write", exc_info=True) + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="communication_failed" + ) from exc + except BaseError as exc: + LOGGER.debug("Failed to write", exc_info=True) + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="rejected" + ) from exc + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/togrill/manifest.json b/homeassistant/components/togrill/manifest.json index 9f9ad8c3782..4b833aec4ee 100644 --- a/homeassistant/components/togrill/manifest.json +++ b/homeassistant/components/togrill/manifest.json @@ -13,6 +13,7 @@ "dependencies": ["bluetooth"], "documentation": "https://www.home-assistant.io/integrations/togrill", "iot_class": "local_push", + "loggers": ["togrill_bluetooth"], "quality_scale": "bronze", "requirements": ["togrill-bluetooth==0.7.0"] } diff --git a/homeassistant/components/togrill/number.py b/homeassistant/components/togrill/number.py new file mode 100644 index 00000000000..a87fec8d2d3 --- /dev/null +++ b/homeassistant/components/togrill/number.py @@ -0,0 +1,138 @@ +"""Support for number entities.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any + +from togrill_bluetooth.packets import ( + PacketA0Notify, + PacketA6Write, + PacketA8Notify, + PacketA301Write, + PacketWrite, +) + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import UnitOfTemperature, UnitOfTime +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 ToGrillNumberEntityDescription(NumberEntityDescription): + """Description of entity.""" + + get_value: Callable[[ToGrillCoordinator], float | None] + set_packet: Callable[[float], PacketWrite] + entity_supported: Callable[[Mapping[str, Any]], bool] = lambda _: True + + +def _get_temperature_target_description( + probe_number: int, +) -> ToGrillNumberEntityDescription: + def _set_packet(value: float | None) -> PacketWrite: + if value == 0.0: + value = None + return PacketA301Write(probe=probe_number, target=value) + + def _get_value(coordinator: ToGrillCoordinator) -> float | None: + if packet := coordinator.get_packet(PacketA8Notify, probe_number): + if packet.alarm_type == PacketA8Notify.AlarmType.TEMPERATURE_TARGET: + return packet.temperature_1 + return None + + return ToGrillNumberEntityDescription( + key=f"temperature_target_{probe_number}", + translation_key="temperature_target", + translation_placeholders={"probe_number": f"{probe_number}"}, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + native_min_value=0, + native_max_value=250, + mode=NumberMode.BOX, + set_packet=_set_packet, + get_value=_get_value, + entity_supported=lambda x: probe_number <= x[CONF_PROBE_COUNT], + ) + + +ENTITY_DESCRIPTIONS = ( + *[ + _get_temperature_target_description(probe_number) + for probe_number in range(1, MAX_PROBE_COUNT + 1) + ], + ToGrillNumberEntityDescription( + key="alarm_interval", + translation_key="alarm_interval", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + native_min_value=0, + native_max_value=15, + native_step=5, + mode=NumberMode.BOX, + set_packet=lambda x: ( + PacketA6Write(temperature_unit=None, alarm_interval=round(x)) + ), + get_value=lambda x: ( + packet.alarm_interval if (packet := x.get_packet(PacketA0Notify)) else None + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ToGrillConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up number based on a config entry.""" + + coordinator = entry.runtime_data + + async_add_entities( + ToGrillNumber(coordinator, entity_description) + for entity_description in ENTITY_DESCRIPTIONS + if entity_description.entity_supported(entry.data) + ) + + +class ToGrillNumber(ToGrillEntity, NumberEntity): + """Representation of a number.""" + + entity_description: ToGrillNumberEntityDescription + + def __init__( + self, + coordinator: ToGrillCoordinator, + entity_description: ToGrillNumberEntityDescription, + ) -> None: + """Initialize.""" + + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = f"{coordinator.address}_{entity_description.key}" + + @property + def native_value(self) -> float | None: + """Return the value reported by the number.""" + return self.entity_description.get_value(self.coordinator) + + async def async_set_native_value(self, value: float) -> None: + """Set value on device.""" + + packet = self.entity_description.set_packet(value) + await self._write_packet(packet) diff --git a/homeassistant/components/togrill/sensor.py b/homeassistant/components/togrill/sensor.py index 7298e4b971b..1641236bfc1 100644 --- a/homeassistant/components/togrill/sensor.py +++ b/homeassistant/components/togrill/sensor.py @@ -122,6 +122,8 @@ class ToGrillSensor(ToGrillEntity, SensorEntity): @property def native_value(self) -> StateType: """Get current value.""" - if packet := self.coordinator.data.get(self.entity_description.packet_type): + if packet := self.coordinator.data.get( + (self.entity_description.packet_type, None) + ): return self.entity_description.packet_extract(packet) return None diff --git a/homeassistant/components/togrill/strings.json b/homeassistant/components/togrill/strings.json index 1b75e387221..a49b6613d3c 100644 --- a/homeassistant/components/togrill/strings.json +++ b/homeassistant/components/togrill/strings.json @@ -22,11 +22,30 @@ "failed_to_read_config": "Failed to read config from device" } }, + "exceptions": { + "disconnected": { + "message": "The device is disconnected" + }, + "communication_failed": { + "message": "Communication failed with the device" + }, + "rejected": { + "message": "Data was rejected by device" + } + }, "entity": { "sensor": { "temperature": { "name": "Probe {probe_number}" } + }, + "number": { + "temperature_target": { + "name": "Target {probe_number}" + }, + "alarm_interval": { + "name": "Alarm interval" + } } } } diff --git a/tests/components/togrill/snapshots/test_number.ambr b/tests/components/togrill/snapshots/test_number.ambr new file mode 100644 index 00000000000..639f2758c69 --- /dev/null +++ b/tests/components/togrill/snapshots/test_number.ambr @@ -0,0 +1,355 @@ +# serializer version: 1 +# name: test_setup[no_data][number.pro_05_alarm_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 15, + 'min': 0, + 'mode': , + 'step': 5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.pro_05_alarm_interval', + '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 interval', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_interval', + 'unique_id': '00000000-0000-0000-0000-000000000001_alarm_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[no_data][number.pro_05_alarm_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Pro-05 Alarm interval', + 'max': 15, + 'min': 0, + 'mode': , + 'step': 5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pro_05_alarm_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_setup[no_data][number.pro_05_target_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 250, + 'min': 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.pro_05_target_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': 'Target 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_target', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_target_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[no_data][number.pro_05_target_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Target 1', + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pro_05_target_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[no_data][number.pro_05_target_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 250, + 'min': 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.pro_05_target_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': 'Target 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_target', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_target_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[no_data][number.pro_05_target_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Target 2', + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pro_05_target_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.pro_05_alarm_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 15, + 'min': 0, + 'mode': , + 'step': 5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.pro_05_alarm_interval', + '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 interval', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_interval', + 'unique_id': '00000000-0000-0000-0000-000000000001_alarm_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.pro_05_alarm_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Pro-05 Alarm interval', + 'max': 15, + 'min': 0, + 'mode': , + 'step': 5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pro_05_alarm_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.pro_05_target_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 250, + 'min': 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.pro_05_target_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': 'Target 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_target', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_target_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.pro_05_target_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Target 1', + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pro_05_target_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.pro_05_target_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 250, + 'min': 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.pro_05_target_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': 'Target 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_target', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_target_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.pro_05_target_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Target 2', + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pro_05_target_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/togrill/test_number.py b/tests/components/togrill/test_number.py new file mode 100644 index 00000000000..05ef6b49d07 --- /dev/null +++ b/tests/components/togrill/test_number.py @@ -0,0 +1,243 @@ +"""Test numbers for ToGrill integration.""" + +from unittest.mock import Mock + +from bleak.exc import BleakError +import pytest +from syrupy.assertion import SnapshotAssertion +from togrill_bluetooth.exceptions import BaseError +from togrill_bluetooth.packets import ( + PacketA0Notify, + PacketA6Write, + PacketA8Notify, + PacketA301Write, +) + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import 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, + ), + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_TARGET, + temperature_1=50.0, + ), + PacketA8Notify(probe=2, alarm_type=None), + ], + id="one_probe_with_target_alarm", + ), + ], +) +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + packets, +) -> None: + """Test the numbers.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.NUMBER]) + + for packet in packets: + mock_client.mocked_notify(packet) + + await snapshot_platform(hass, entity_registry, snapshot, mock_entry.entry_id) + + +@pytest.mark.parametrize( + ("packets", "entity_id", "value", "write_packet"), + [ + pytest.param( + [ + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_TARGET, + temperature_1=50.0, + ), + ], + "number.pro_05_target_1", + 100.0, + PacketA301Write(probe=1, target=100), + id="probe", + ), + pytest.param( + [ + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_TARGET, + temperature_1=50.0, + ), + ], + "number.pro_05_target_1", + 0.0, + PacketA301Write(probe=1, target=None), + id="probe_clear", + ), + 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, + ) + ], + "number.pro_05_alarm_interval", + 15, + PacketA6Write(temperature_unit=None, alarm_interval=15), + id="alarm_interval", + ), + ], +) +async def test_set_number( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_client: Mock, + packets, + entity_id, + value, + write_packet, +) -> None: + """Test the number set.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.NUMBER]) + + for packet in packets: + mock_client.mocked_notify(packet) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ + ATTR_VALUE: value, + }, + target={ + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + mock_client.write.assert_any_call(write_packet) + + +@pytest.mark.parametrize( + ("error", "message"), + [ + pytest.param( + BleakError("Some error"), + "Communication failed with the device", + id="bleak", + ), + pytest.param( + BaseError("Some error"), + "Data was rejected by device", + id="base", + ), + ], +) +async def test_set_number_write_error( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_client: Mock, + error, + message, +) -> None: + """Test the number set.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.NUMBER]) + + mock_client.mocked_notify( + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_TARGET, + temperature_1=50.0, + ), + ) + mock_client.write.side_effect = error + + with pytest.raises(HomeAssistantError, match=message): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ + ATTR_VALUE: 100, + }, + target={ + ATTR_ENTITY_ID: "number.pro_05_target_1", + }, + blocking=True, + ) + + +async def test_set_number_disconnected( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_client: Mock, +) -> None: + """Test the number set.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.NUMBER]) + + mock_client.mocked_notify( + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_TARGET, + temperature_1=50.0, + ), + ) + mock_client.is_connected = False + + with pytest.raises(HomeAssistantError, match=""): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ + ATTR_VALUE: 100, + }, + target={ + ATTR_ENTITY_ID: "number.pro_05_target_1", + }, + blocking=True, + ) From 2b7bd923d642d8b50c11b55db3c067226ee292c5 Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Sun, 17 Aug 2025 22:18:08 -0700 Subject: [PATCH 1094/1113] Add a base entity to APCUPSD integration (#150828) --- .../components/apcupsd/binary_sensor.py | 12 +++------ homeassistant/components/apcupsd/entity.py | 26 +++++++++++++++++++ .../components/apcupsd/quality_scale.yaml | 5 +--- homeassistant/components/apcupsd/sensor.py | 12 +++------ 4 files changed, 33 insertions(+), 22 deletions(-) create mode 100644 homeassistant/components/apcupsd/entity.py diff --git a/homeassistant/components/apcupsd/binary_sensor.py b/homeassistant/components/apcupsd/binary_sensor.py index dfeb56c8d06..394ff4c4088 100644 --- a/homeassistant/components/apcupsd/binary_sensor.py +++ b/homeassistant/components/apcupsd/binary_sensor.py @@ -10,9 +10,9 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator +from .entity import APCUPSdEntity PARALLEL_UPDATES = 0 @@ -40,22 +40,16 @@ async def async_setup_entry( async_add_entities([OnlineStatus(coordinator, _DESCRIPTION)]) -class OnlineStatus(CoordinatorEntity[APCUPSdCoordinator], BinarySensorEntity): +class OnlineStatus(APCUPSdEntity, BinarySensorEntity): """Representation of a UPS online status.""" - _attr_has_entity_name = True - def __init__( self, coordinator: APCUPSdCoordinator, description: BinarySensorEntityDescription, ) -> None: """Initialize the APCUPSd binary device.""" - super().__init__(coordinator, context=description.key.upper()) - - self.entity_description = description - self._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}" - self._attr_device_info = coordinator.device_info + super().__init__(coordinator, description) @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/apcupsd/entity.py b/homeassistant/components/apcupsd/entity.py new file mode 100644 index 00000000000..9ebe51ff876 --- /dev/null +++ b/homeassistant/components/apcupsd/entity.py @@ -0,0 +1,26 @@ +"""Base entity for APCUPSd integration.""" + +from __future__ import annotations + +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import APCUPSdCoordinator + + +class APCUPSdEntity(CoordinatorEntity[APCUPSdCoordinator]): + """Base entity for APCUPSd integration.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: APCUPSdCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the APCUPSd entity.""" + super().__init__(coordinator, context=description.key.upper()) + + self.entity_description = description + self._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}" + self._attr_device_info = coordinator.device_info diff --git a/homeassistant/components/apcupsd/quality_scale.yaml b/homeassistant/components/apcupsd/quality_scale.yaml index 316f3e97bbe..23b72134d34 100644 --- a/homeassistant/components/apcupsd/quality_scale.yaml +++ b/homeassistant/components/apcupsd/quality_scale.yaml @@ -3,10 +3,7 @@ rules: action-setup: done appropriate-polling: done brands: done - common-modules: - status: done - comment: | - Consider deriving a base entity. + common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 5076b537467..14baed5bfce 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -23,10 +23,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import LAST_S_TEST from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator +from .entity import APCUPSdEntity PARALLEL_UPDATES = 0 @@ -490,22 +490,16 @@ def infer_unit(value: str) -> tuple[str, str | None]: return value, None -class APCUPSdSensor(CoordinatorEntity[APCUPSdCoordinator], SensorEntity): +class APCUPSdSensor(APCUPSdEntity, SensorEntity): """Representation of a sensor entity for APCUPSd status values.""" - _attr_has_entity_name = True - def __init__( self, coordinator: APCUPSdCoordinator, description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator=coordinator, context=description.key.upper()) - - self.entity_description = description - self._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}" - self._attr_device_info = coordinator.device_info + super().__init__(coordinator, description) # Initial update of attributes. self._update_attrs() From f44578f45f5cf1b1c3653019cb6f2b6e293bb9af Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Sun, 17 Aug 2025 22:19:47 -0700 Subject: [PATCH 1095/1113] Add more exception types for `cannot_connect` test in APCUPSD (#150830) --- tests/components/apcupsd/test_config_flow.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py index 6ecaa533423..0a61d8c0ddb 100644 --- a/tests/components/apcupsd/test_config_flow.py +++ b/tests/components/apcupsd/test_config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from unittest.mock import AsyncMock, patch import pytest @@ -17,12 +18,18 @@ from . import CONF_DATA, MOCK_MINIMAL_STATUS, MOCK_STATUS from tests.common import MockConfigEntry -async def test_config_flow_cannot_connect(hass: HomeAssistant) -> None: - """Test config flow setup with connection error.""" +@pytest.mark.parametrize( + "exception", + [OSError(), asyncio.IncompleteReadError(partial=b"", expected=100), TimeoutError()], +) +async def test_config_flow_cannot_connect( + hass: HomeAssistant, exception: Exception +) -> None: + """Test config flow setup with a connection error.""" with patch( "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status" - ) as mock_get: - mock_get.side_effect = OSError() + ) as mock_request_status: + mock_request_status.side_effect = exception result = await hass.config_entries.flow.async_init( DOMAIN, From fcbfca52f3d5ecb27d049375a163ff15ae25af6e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 18 Aug 2025 08:28:58 +0200 Subject: [PATCH 1096/1113] Bump spotifyaio to 1.0.0 (#150820) --- .../components/spotify/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/spotify/conftest.py | 4 +- .../spotify/snapshots/test_diagnostics.ambr | 54 +++++++++++++++++++ 5 files changed, 59 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 80fcc777e73..ac7f575bcc5 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -8,5 +8,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["spotifyaio"], - "requirements": ["spotifyaio==0.8.11"] + "requirements": ["spotifyaio==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b783b88fd0e..b5d85bf53ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2823,7 +2823,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.11 +spotifyaio==1.0.0 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c6a84603533..e415caa01eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2330,7 +2330,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.11 +spotifyaio==1.0.0 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/tests/components/spotify/conftest.py b/tests/components/spotify/conftest.py index 67d4eac3960..9efc453f855 100644 --- a/tests/components/spotify/conftest.py +++ b/tests/components/spotify/conftest.py @@ -8,8 +8,8 @@ import pytest from spotifyaio.models import ( Album, Artist, - ArtistResponse, Devices, + FollowedArtistResponse, NewReleasesResponse, NewReleasesResponseInner, PlaybackState, @@ -138,7 +138,7 @@ def mock_spotify() -> Generator[AsyncMock]: getattr(client, method).return_value = obj.from_json( load_fixture(fixture, DOMAIN) ) - client.get_followed_artists.return_value = ArtistResponse.from_json( + client.get_followed_artists.return_value = FollowedArtistResponse.from_json( load_fixture("followed_artists.json", DOMAIN) ).artists.items client.get_new_releases.return_value = NewReleasesResponse.from_json( diff --git a/tests/components/spotify/snapshots/test_diagnostics.ambr b/tests/components/spotify/snapshots/test_diagnostics.ambr index 0ac375d18e3..8866fa45055 100644 --- a/tests/components/spotify/snapshots/test_diagnostics.ambr +++ b/tests/components/spotify/snapshots/test_diagnostics.ambr @@ -125,6 +125,15 @@ 'tracks': dict({ 'items': list([ dict({ + 'added_at': '2015-01-15T12:39:22+00:00', + 'added_by': dict({ + 'external_urls': dict({ + 'spotify': 'https://open.spotify.com/user/jmperezperez', + }), + 'href': 'https://api.spotify.com/v1/users/jmperezperez', + 'uri': 'spotify:user:jmperezperez', + 'user_id': 'jmperezperez', + }), 'track': dict({ 'album': dict({ 'album_id': '2pANdqPvxInB0YvcDiw4ko', @@ -182,6 +191,15 @@ }), }), dict({ + 'added_at': '2015-01-15T12:40:03+00:00', + 'added_by': dict({ + 'external_urls': dict({ + 'spotify': 'https://open.spotify.com/user/jmperezperez', + }), + 'href': 'https://api.spotify.com/v1/users/jmperezperez', + 'uri': 'spotify:user:jmperezperez', + 'user_id': 'jmperezperez', + }), 'track': dict({ 'album': dict({ 'album_id': '6nlfkk5GoXRL1nktlATNsy', @@ -239,6 +257,15 @@ }), }), dict({ + 'added_at': '2015-01-15T12:22:30+00:00', + 'added_by': dict({ + 'external_urls': dict({ + 'spotify': 'https://open.spotify.com/user/jmperezperez', + }), + 'href': 'https://api.spotify.com/v1/users/jmperezperez', + 'uri': 'spotify:user:jmperezperez', + 'user_id': 'jmperezperez', + }), 'track': dict({ 'album': dict({ 'album_id': '4hnqM0JK4CM1phwfq1Ldyz', @@ -296,6 +323,15 @@ }), }), dict({ + 'added_at': '2015-01-15T12:40:35+00:00', + 'added_by': dict({ + 'external_urls': dict({ + 'spotify': 'https://open.spotify.com/user/jmperezperez', + }), + 'href': 'https://api.spotify.com/v1/users/jmperezperez', + 'uri': 'spotify:user:jmperezperez', + 'user_id': 'jmperezperez', + }), 'track': dict({ 'album': dict({ 'album_id': '2usKFntxa98WHMcyW6xJBz', @@ -353,6 +389,15 @@ }), }), dict({ + 'added_at': '2015-01-15T12:41:10+00:00', + 'added_by': dict({ + 'external_urls': dict({ + 'spotify': 'https://open.spotify.com/user/jmperezperez', + }), + 'href': 'https://api.spotify.com/v1/users/jmperezperez', + 'uri': 'spotify:user:jmperezperez', + 'user_id': 'jmperezperez', + }), 'track': dict({ 'album': dict({ 'album_id': '0ivM6kSawaug0j3tZVusG2', @@ -410,6 +455,15 @@ }), }), dict({ + 'added_at': '2024-11-28T11:20:58+00:00', + 'added_by': dict({ + 'external_urls': dict({ + 'spotify': 'https://open.spotify.com/user/1112264649', + }), + 'href': 'https://api.spotify.com/v1/users/1112264649', + 'uri': 'spotify:user:1112264649', + 'user_id': '1112264649', + }), 'track': dict({ 'description': 'Patreon: https://www.patreon.com/safetythirdMerch: https://safetythird.shopYouTube: https://www.youtube.com/@safetythird/Advertising Inquiries: https://redcircle.com/brandsPrivacy & Opt-Out: https://redcircle.com/privacy', 'duration_ms': 3690161, From 5fdb95e83cd1aba890151e4d3b397a27fad97eec Mon Sep 17 00:00:00 2001 From: Claudio Ruggeri - CR-Tech <41435902+crug80@users.noreply.github.com> Date: Mon, 18 Aug 2025 08:49:09 +0200 Subject: [PATCH 1097/1113] Fix Modbus issue 150453: correct transition update for climate without HVAC mode enabled (#150522) Co-authored-by: jan iversen --- homeassistant/components/modbus/climate.py | 5 + tests/components/modbus/test_climate.py | 134 +++++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index b3d6c78387d..f8e7dca245a 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -490,6 +490,11 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): if hvac_mode == value: self._attr_hvac_mode = mode break + else: + # since there are no hvac_mode_register, this + # integration should not touch the attr. + # However it lacks in the climate component. + self._attr_hvac_mode = HVACMode.AUTO # Read the HVAC action register if defined if self._hvac_action_register is not None: diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 38f2aa3337f..f661dd2083c 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -794,6 +794,140 @@ async def test_hvac_onoff_coil_update( assert state.state == result +@pytest.mark.parametrize( + ( + "do_config", + "result_before", + "coil_value_before", + "result_after", + "coil_value_after", + ), + [ + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 120, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_HVAC_ONOFF_COIL: 11, + }, + ] + }, + HVACMode.OFF, + [0x00], + HVACMode.AUTO, + [0x01], + ), + ], +) +async def test_hvac_onoff_coil_transition_update( + hass: HomeAssistant, + mock_modbus_ha, + result_before, + coil_value_before, + result_after, + coil_value_after, +) -> None: + """Test climate update based on On/Off coil values without hvacmode register.""" + mock_modbus_ha.read_coils.return_value = ReadResult(coil_value_before) + + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == result_before + + mock_modbus_ha.read_coils.return_value = ReadResult(coil_value_after) + + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == result_after + + +@pytest.mark.parametrize( + ( + "do_config", + "result_before", + "register_value_before", + "result_after", + "register_value_after", + ), + [ + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 120, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_HVAC_ONOFF_REGISTER: 11, + }, + ] + }, + HVACMode.OFF, + [0x00], + HVACMode.AUTO, + [0x01], + ), + ], +) +async def test_hvac_onoff_register_transition_update( + hass: HomeAssistant, + mock_modbus_ha, + result_before, + register_value_before, + result_after, + register_value_after, +) -> None: + """Test climate update based on On/Off register values without hvacmode register.""" + mock_modbus_ha.read_holding_registers.return_value = ReadResult( + register_value_before + ) + + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == result_before + + mock_modbus_ha.read_holding_registers.return_value = ReadResult( + register_value_after + ) + + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == result_after + + @pytest.mark.parametrize( ("do_config", "result", "register_words"), [ From a325596898ae00a3fbe7013adf4773df9860afe3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 18 Aug 2025 09:39:08 +0200 Subject: [PATCH 1098/1113] Bump yt-dlp to 2025.08.11 (#150821) --- 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 db622d21f1a..477e77022de 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.07.21"], + "requirements": ["yt-dlp[default]==2025.08.11"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index b5d85bf53ce..3e5adf99f86 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3179,7 +3179,7 @@ youless-api==2.2.0 youtubeaio==2.0.0 # homeassistant.components.media_extractor -yt-dlp[default]==2025.07.21 +yt-dlp[default]==2025.08.11 # homeassistant.components.zabbix zabbix-utils==2.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e415caa01eb..f3ab6c65efa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2626,7 +2626,7 @@ youless-api==2.2.0 youtubeaio==2.0.0 # homeassistant.components.media_extractor -yt-dlp[default]==2025.07.21 +yt-dlp[default]==2025.08.11 # homeassistant.components.zamg zamg==0.3.6 From 9138930cb9ba16e11055ede542bf397ac8499978 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 18 Aug 2025 09:41:37 +0200 Subject: [PATCH 1099/1113] Abort Nanoleaf discovery flows with user flow (#150818) --- .../components/nanoleaf/config_flow.py | 11 +++- tests/components/nanoleaf/test_config_flow.py | 57 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nanoleaf/config_flow.py b/homeassistant/components/nanoleaf/config_flow.py index 253387c254a..d62168a4ad3 100644 --- a/homeassistant/components/nanoleaf/config_flow.py +++ b/homeassistant/components/nanoleaf/config_flow.py @@ -10,7 +10,12 @@ from typing import Any, Final, cast from aionanoleaf import InvalidToken, Nanoleaf, Unauthorized, Unavailable import voluptuous as vol -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_USER, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.json import save_json @@ -200,7 +205,9 @@ class NanoleafConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="unknown") name = self.nanoleaf.name - await self.async_set_unique_id(name) + await self.async_set_unique_id( + name, raise_on_progress=self.source != SOURCE_USER + ) self._abort_if_unique_id_configured({CONF_HOST: self.nanoleaf.host}) if discovery_integration_import: diff --git a/tests/components/nanoleaf/test_config_flow.py b/tests/components/nanoleaf/test_config_flow.py index ba89405bc97..d9616572b2e 100644 --- a/tests/components/nanoleaf/test_config_flow.py +++ b/tests/components/nanoleaf/test_config_flow.py @@ -10,6 +10,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.nanoleaf.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -463,3 +464,59 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None: } assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_abort_discovery_flow_with_user_flow(hass: HomeAssistant) -> None: + """Test abort discovery flow if user flow is already in progress.""" + with ( + patch( + "homeassistant.components.nanoleaf.config_flow.load_json_object", + return_value={}, + ), + patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf", + return_value=_mock_nanoleaf(TEST_HOST, TEST_TOKEN), + ), + patch( + "homeassistant.components.nanoleaf.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + upnp={}, + ssdp_headers={ + "_host": TEST_HOST, + "nl-devicename": TEST_NAME, + "nl-deviceid": TEST_DEVICE_ID, + }, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + assert result["step_id"] == "link" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert len(hass.config_entries.flow.async_progress(DOMAIN)) == 2 + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "link" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.CREATE_ENTRY + + # Verify the discovery flow was aborted + assert not hass.config_entries.flow.async_progress(DOMAIN) From 419315d9cf6741928b03a483df4835a307079df2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 18 Aug 2025 09:47:02 +0200 Subject: [PATCH 1100/1113] Clean up freebox entity (#150695) --- .../components/freebox/alarm_control_panel.py | 8 +++---- .../components/freebox/binary_sensor.py | 15 +++++------- homeassistant/components/freebox/camera.py | 2 +- homeassistant/components/freebox/entity.py | 23 +++---------------- homeassistant/components/freebox/sensor.py | 21 +++++++++-------- 5 files changed, 24 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/freebox/alarm_control_panel.py b/homeassistant/components/freebox/alarm_control_panel.py index b0242a1b054..968f3dc16a6 100644 --- a/homeassistant/components/freebox/alarm_control_panel.py +++ b/homeassistant/components/freebox/alarm_control_panel.py @@ -36,7 +36,7 @@ async def async_setup_entry( async_add_entities( ( - FreeboxAlarm(hass, router, node) + FreeboxAlarm(router, node) for node in router.home_devices.values() if node["category"] == FreeboxHomeCategory.ALARM ), @@ -49,11 +49,9 @@ class FreeboxAlarm(FreeboxHomeEntity, AlarmControlPanelEntity): _attr_code_arm_required = False - def __init__( - self, hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any] - ) -> None: + def __init__(self, router: FreeboxRouter, node: dict[str, Any]) -> None: """Initialize an alarm.""" - super().__init__(hass, router, node) + super().__init__(router, node) # Commands self._command_trigger = self.get_command_id( diff --git a/homeassistant/components/freebox/binary_sensor.py b/homeassistant/components/freebox/binary_sensor.py index 75b7dded36a..3b262309361 100644 --- a/homeassistant/components/freebox/binary_sensor.py +++ b/homeassistant/components/freebox/binary_sensor.py @@ -50,12 +50,12 @@ async def async_setup_entry( for node in router.home_devices.values(): if node["category"] == FreeboxHomeCategory.PIR: - binary_entities.append(FreeboxPirSensor(hass, router, node)) + binary_entities.append(FreeboxPirSensor(router, node)) elif node["category"] == FreeboxHomeCategory.DWS: - binary_entities.append(FreeboxDwsSensor(hass, router, node)) + binary_entities.append(FreeboxDwsSensor(router, node)) binary_entities.extend( - FreeboxCoverSensor(hass, router, node) + FreeboxCoverSensor(router, node) for endpoint in node["show_endpoints"] if ( endpoint["name"] == "cover" @@ -74,13 +74,12 @@ class FreeboxHomeBinarySensor(FreeboxHomeEntity, BinarySensorEntity): def __init__( self, - hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any], sub_node: dict[str, Any] | None = None, ) -> None: """Initialize a Freebox binary sensor.""" - super().__init__(hass, router, node, sub_node) + super().__init__(router, node, sub_node) self._command_id = self.get_command_id( node["type"]["endpoints"], "signal", self._sensor_name ) @@ -123,9 +122,7 @@ class FreeboxCoverSensor(FreeboxHomeBinarySensor): _sensor_name = "cover" - def __init__( - self, hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any] - ) -> None: + def __init__(self, router: FreeboxRouter, node: dict[str, Any]) -> None: """Initialize a cover for another device.""" cover_node = next( filter( @@ -134,7 +131,7 @@ class FreeboxCoverSensor(FreeboxHomeBinarySensor): ), None, ) - super().__init__(hass, router, node, cover_node) + super().__init__(router, node, cover_node) class FreeboxRaidDegradedSensor(BinarySensorEntity): diff --git a/homeassistant/components/freebox/camera.py b/homeassistant/components/freebox/camera.py index d997908dd06..f7e078f0736 100644 --- a/homeassistant/components/freebox/camera.py +++ b/homeassistant/components/freebox/camera.py @@ -74,7 +74,7 @@ class FreeboxCamera(FreeboxHomeEntity, FFmpegCamera): ) -> None: """Initialize a camera.""" - super().__init__(hass, router, node) + super().__init__(router, node) device_info = { CONF_NAME: node["label"].strip(), CONF_INPUT: node["props"]["Stream"], diff --git a/homeassistant/components/freebox/entity.py b/homeassistant/components/freebox/entity.py index 129186fd50b..17cd30f40ea 100644 --- a/homeassistant/components/freebox/entity.py +++ b/homeassistant/components/freebox/entity.py @@ -2,11 +2,9 @@ from __future__ import annotations -from collections.abc import Callable import logging from typing import Any -from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity @@ -22,13 +20,11 @@ class FreeboxHomeEntity(Entity): def __init__( self, - hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any], sub_node: dict[str, Any] | None = None, ) -> None: """Initialize a Freebox Home entity.""" - self._hass = hass self._router = router self._node = node self._sub_node = sub_node @@ -44,7 +40,6 @@ class FreeboxHomeEntity(Entity): self._available = True self._firmware = node["props"].get("FwVersion") self._manufacturer = "Freebox SAS" - self._remove_signal_update: Callable[[], None] | None = None self._model = CATEGORY_TO_MODEL.get(node["category"]) if self._model is None: @@ -61,10 +56,7 @@ class FreeboxHomeEntity(Entity): model=self._model, name=self._device_name, sw_version=self._firmware, - via_device=( - DOMAIN, - router.mac, - ), + via_device=(DOMAIN, router.mac), ) async def async_update_signal(self) -> None: @@ -116,23 +108,14 @@ class FreeboxHomeEntity(Entity): async def async_added_to_hass(self) -> None: """Register state update callback.""" - self.remove_signal_update( + self.async_on_remove( async_dispatcher_connect( - self._hass, + self.hass, self._router.signal_home_device_update, self.async_update_signal, ) ) - async def async_will_remove_from_hass(self) -> None: - """When entity will be removed from hass.""" - if self._remove_signal_update is not None: - self._remove_signal_update() - - def remove_signal_update(self, dispatcher: Callable[[], None]) -> None: - """Register state update callback.""" - self._remove_signal_update = dispatcher - def get_value(self, ep_type: str, name: str): """Get the value.""" node = next( diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index 45fe18db95a..53314549f57 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -68,7 +68,6 @@ async def async_setup_entry( ) -> None: """Set up the sensors.""" router = entry.runtime_data - entities: list[SensorEntity] = [] _LOGGER.debug( "%s - %s - %s temperature sensors", @@ -76,7 +75,7 @@ async def async_setup_entry( router.mac, len(router.sensors_temperature), ) - entities = [ + entities: list[SensorEntity] = [ FreeboxSensor( router, SensorEntityDescription( @@ -105,14 +104,16 @@ async def async_setup_entry( for description in DISK_PARTITION_SENSORS ) - for node in router.home_devices.values(): - for endpoint in node["show_endpoints"]: - if ( - endpoint["name"] == "battery" - and endpoint["ep_type"] == "signal" - and endpoint.get("value") is not None - ): - entities.append(FreeboxBatterySensor(hass, router, node, endpoint)) + entities.extend( + FreeboxBatterySensor(router, node, endpoint) + for node in router.home_devices.values() + for endpoint in node["show_endpoints"] + if ( + endpoint["name"] == "battery" + and endpoint["ep_type"] == "signal" + and endpoint.get("value") is not None + ) + ) if entities: async_add_entities(entities, True) From 2f8ddae24d5dfc0c7e6f5aa87f89872c0d2639cd Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 18 Aug 2025 10:04:54 +0200 Subject: [PATCH 1101/1113] Include device data in Withings diagnostics (#150816) --- .../components/withings/diagnostics.py | 11 ++++++ .../withings/snapshots/test_diagnostics.ambr | 36 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/homeassistant/components/withings/diagnostics.py b/homeassistant/components/withings/diagnostics.py index d8b59075368..dd154488be2 100644 --- a/homeassistant/components/withings/diagnostics.py +++ b/homeassistant/components/withings/diagnostics.py @@ -2,16 +2,23 @@ from __future__ import annotations +from dataclasses import asdict from typing import Any from yarl import URL +from homeassistant.components.diagnostics import async_redact_data from homeassistant.components.webhook import async_generate_url as webhook_generate_url from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from . import CONF_CLOUDHOOK_URL, WithingsConfigEntry +TO_REDACT = { + "device_id", + "hashed_device_id", +} + async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: WithingsConfigEntry @@ -53,4 +60,8 @@ async def async_get_config_entry_diagnostics( "received_sleep_data": withings_data.sleep_coordinator.data is not None, "received_workout_data": withings_data.workout_coordinator.data is not None, "received_activity_data": withings_data.activity_coordinator.data is not None, + "devices": async_redact_data( + [asdict(v) for v in withings_data.device_coordinator.data.values()], + TO_REDACT, + ), } diff --git a/tests/components/withings/snapshots/test_diagnostics.ambr b/tests/components/withings/snapshots/test_diagnostics.ambr index f7c704a2c49..bfd56fbc4d4 100644 --- a/tests/components/withings/snapshots/test_diagnostics.ambr +++ b/tests/components/withings/snapshots/test_diagnostics.ambr @@ -1,6 +1,18 @@ # serializer version: 1 # name: test_diagnostics_cloudhook_instance dict({ + 'devices': list([ + dict({ + 'battery': 'high', + 'device_id': '**REDACTED**', + 'device_type': 'Scale', + 'first_session_date': None, + 'hashed_device_id': '**REDACTED**', + 'last_session_date': '2023-09-04T22:39:39+00:00', + 'model': 5, + 'raw_model': 'Body+', + }), + ]), 'has_cloudhooks': True, 'has_valid_external_webhook_url': True, 'received_activity_data': False, @@ -64,6 +76,18 @@ # --- # name: test_diagnostics_polling_instance dict({ + 'devices': list([ + dict({ + 'battery': 'high', + 'device_id': '**REDACTED**', + 'device_type': 'Scale', + 'first_session_date': None, + 'hashed_device_id': '**REDACTED**', + 'last_session_date': '2023-09-04T22:39:39+00:00', + 'model': 5, + 'raw_model': 'Body+', + }), + ]), 'has_cloudhooks': False, 'has_valid_external_webhook_url': False, 'received_activity_data': False, @@ -127,6 +151,18 @@ # --- # name: test_diagnostics_webhook_instance dict({ + 'devices': list([ + dict({ + 'battery': 'high', + 'device_id': '**REDACTED**', + 'device_type': 'Scale', + 'first_session_date': None, + 'hashed_device_id': '**REDACTED**', + 'last_session_date': '2023-09-04T22:39:39+00:00', + 'model': 5, + 'raw_model': 'Body+', + }), + ]), 'has_cloudhooks': False, 'has_valid_external_webhook_url': True, 'received_activity_data': False, From 330bb46cf940f5cf08f15fefab947b7ec04b5883 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 18 Aug 2025 10:07:57 +0200 Subject: [PATCH 1102/1113] Revert "Bump automower-ble to 0.2.7" (#150833) --- 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 50430c2a9fa..6eb618cbb04 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.7"] + "requirements": ["automower-ble==0.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3e5adf99f86..767c2e4d044 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -563,7 +563,7 @@ aurorapy==0.2.7 autarco==3.1.0 # homeassistant.components.husqvarna_automower_ble -automower-ble==0.2.7 +automower-ble==0.2.1 # homeassistant.components.generic # homeassistant.components.stream diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f3ab6c65efa..5dadc183532 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -518,7 +518,7 @@ aurorapy==0.2.7 autarco==3.1.0 # homeassistant.components.husqvarna_automower_ble -automower-ble==0.2.7 +automower-ble==0.2.1 # homeassistant.components.generic # homeassistant.components.stream From 2f5561aeba30828b9dfe6b90de275ad23d98118e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 18 Aug 2025 11:32:08 +0200 Subject: [PATCH 1103/1113] Matter Custom Eve Weather trend (#147620) --- homeassistant/components/matter/icons.json | 9 +++ homeassistant/components/matter/sensor.py | 21 +++++++ homeassistant/components/matter/strings.json | 9 +++ .../matter/snapshots/test_sensor.ambr | 62 +++++++++++++++++++ 4 files changed, 101 insertions(+) diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 4bf2350738f..dc1fbc25181 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -57,6 +57,15 @@ "current_phase": { "default": "mdi:state-machine" }, + "eve_weather_trend": { + "default": "mdi:weather", + "state": { + "sunny": "mdi:weather-sunny", + "cloudy": "mdi:weather-cloudy", + "rainy": "mdi:weather-rainy", + "stormy": "mdi:weather-windy" + } + }, "air_quality": { "default": "mdi:air-filter" }, diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index af7dd385fd0..18bd7f84da3 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -70,6 +70,14 @@ CONTAMINATION_STATE_MAP = { clusters.SmokeCoAlarm.Enums.ContaminationStateEnum.kCritical: "critical", } +EVE_CLUSTER_WEATHER_MAP = { + # enum with known Weather state values which we can translate + 1: "sunny", + 3: "cloudy", + 6: "rainy", + 14: "stormy", +} + OPERATIONAL_STATE_MAP = { # enum with known Operation state values which we can translate clusters.OperationalState.Enums.OperationalStateEnum.kStopped: "stopped", @@ -517,6 +525,19 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterSensor, required_attributes=(EveCluster.Attributes.Pressure,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveWeatherWeatherTrend", + translation_key="eve_weather_trend", + device_class=SensorDeviceClass.ENUM, + native_unit_of_measurement=None, + options=[x for x in EVE_CLUSTER_WEATHER_MAP.values() if x is not None], + device_to_ha=EVE_CLUSTER_WEATHER_MAP.get, + ), + entity_class=MatterSensor, + required_attributes=(EveCluster.Attributes.WeatherTrend,), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 8cd2fdf6adf..f45baf8729d 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -434,6 +434,15 @@ "pump_speed": { "name": "Rotation speed" }, + "eve_weather_trend": { + "name": "Weather trend", + "state": { + "cloudy": "Cloudy", + "rainy": "Rainy", + "sunny": "Sunny", + "stormy": "Stormy" + } + }, "evse_circuit_capacity": { "name": "Circuit capacity" }, diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index b7aff460d77..eb34c7302e3 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -2906,6 +2906,68 @@ 'state': '16.03', }) # --- +# name: test_sensors[eve_weather_sensor][sensor.eve_weather_weather_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'sunny', + 'cloudy', + 'rainy', + 'stormy', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_weather_weather_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Weather trend', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'eve_weather_trend', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-EveWeatherWeatherTrend-319486977-319422485', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[eve_weather_sensor][sensor.eve_weather_weather_trend-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Eve Weather Weather trend', + 'options': list([ + 'sunny', + 'cloudy', + 'rainy', + 'stormy', + ]), + }), + 'context': , + 'entity_id': 'sensor.eve_weather_weather_trend', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'rainy', + }) +# --- # name: test_sensors[extractor_hood][sensor.mock_extractor_hood_activated_carbon_filter_condition-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 6baa162963481ca3a5593326ea9b355a0ee9d942 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 18 Aug 2025 12:04:52 +0200 Subject: [PATCH 1104/1113] Bump brother to version 5.0.1 (#150840) --- homeassistant/components/brother/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index deae818e2b5..356ba4f01fc 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==5.0.0"], + "requirements": ["brother==5.0.1"], "zeroconf": [ { "type": "_printer._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 767c2e4d044..afe436cfde1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -686,7 +686,7 @@ bring-api==1.1.0 broadlink==0.19.0 # homeassistant.components.brother -brother==5.0.0 +brother==5.0.1 # homeassistant.components.brottsplatskartan brottsplatskartan==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5dadc183532..d98fc9d1491 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -613,7 +613,7 @@ bring-api==1.1.0 broadlink==0.19.0 # homeassistant.components.brother -brother==5.0.0 +brother==5.0.1 # homeassistant.components.brottsplatskartan brottsplatskartan==1.0.5 From 7ecbe53b153e3bee0c65019007cd81f2b403ac66 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 18 Aug 2025 12:05:10 +0200 Subject: [PATCH 1105/1113] Bump brother to version 5.0.1 (#150840) From 99104809807de282b6141edb45786a9d937e4942 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 18 Aug 2025 12:06:09 +0200 Subject: [PATCH 1106/1113] Bump aiontfy to v0.5.4 (#150825) --- homeassistant/components/ntfy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ntfy/manifest.json b/homeassistant/components/ntfy/manifest.json index d9d864d10a3..f041b02b6d6 100644 --- a/homeassistant/components/ntfy/manifest.json +++ b/homeassistant/components/ntfy/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["aionfty"], "quality_scale": "bronze", - "requirements": ["aiontfy==0.5.3"] + "requirements": ["aiontfy==0.5.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index afe436cfde1..cba0201941b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -325,7 +325,7 @@ aionanoleaf==0.2.1 aionotion==2024.03.0 # homeassistant.components.ntfy -aiontfy==0.5.3 +aiontfy==0.5.4 # homeassistant.components.nut aionut==4.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d98fc9d1491..b9fd9d9e5f5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -307,7 +307,7 @@ aionanoleaf==0.2.1 aionotion==2024.03.0 # homeassistant.components.ntfy -aiontfy==0.5.3 +aiontfy==0.5.4 # homeassistant.components.nut aionut==4.3.4 From 53ca36939556241b2bbb23ef2cfa6a1889c3050c Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 18 Aug 2025 17:20:41 +0200 Subject: [PATCH 1107/1113] Do not start modbus update process until connection+delay. (#150796) --- homeassistant/components/modbus/entity.py | 14 ++++++++++-- homeassistant/components/modbus/modbus.py | 27 ++++++----------------- tests/components/modbus/conftest.py | 11 +++++++++ tests/components/modbus/test_init.py | 16 ++++++++++---- 4 files changed, 42 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py index eaf13d5bca4..cde017d4dd7 100644 --- a/homeassistant/components/modbus/entity.py +++ b/homeassistant/components/modbus/entity.py @@ -92,7 +92,6 @@ class BasePlatform(Entity): self._scan_interval = int(entry[CONF_SCAN_INTERVAL]) self._cancel_timer: Callable[[], None] | None = None self._cancel_call: Callable[[], None] | None = None - self._attr_unique_id = entry.get(CONF_UNIQUE_ID) self._attr_name = entry[CONF_NAME] self._attr_device_class = entry.get(CONF_DEVICE_CLASS) @@ -177,9 +176,20 @@ class BasePlatform(Entity): self._attr_available = False self.async_write_ha_state() + async def async_await_connection(self, _now: Any) -> None: + """Wait for first connect.""" + await self._hub.event_connected.wait() + self.async_run() + async def async_base_added_to_hass(self) -> None: """Handle entity which will be added.""" - self.async_run() + self.async_on_remove( + async_call_later( + self.hass, + self._hub.config_delay + 0.1, + self.async_await_connection, + ) + ) self.async_on_remove( async_dispatcher_connect(self.hass, SIGNAL_STOP_ENTITY, self.async_hold) ) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 186720bb40a..7343ffd1787 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections import namedtuple -from collections.abc import Callable from typing import Any from pymodbus.client import ( @@ -28,11 +27,10 @@ from homeassistant.const import ( CONF_TYPE, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall, callback +from homeassistant.core import Event, HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey @@ -254,13 +252,13 @@ class ModbusHub: self._client: ( AsyncModbusSerialClient | AsyncModbusTcpClient | AsyncModbusUdpClient | None ) = None - self._async_cancel_listener: Callable[[], None] | None = None self._in_error = False self._lock = asyncio.Lock() + self.event_connected = asyncio.Event() self.hass = hass self.name = client_config[CONF_NAME] self._config_type = client_config[CONF_TYPE] - self._config_delay = client_config[CONF_DELAY] + self.config_delay = client_config[CONF_DELAY] self._pb_request: dict[str, RunEntry] = {} self._connect_task: asyncio.Task self._last_log_error: str = "" @@ -325,10 +323,10 @@ class ModbusHub: _LOGGER.info(message) # Start counting down to allow modbus requests. - if self._config_delay: - self._async_cancel_listener = async_call_later( - self.hass, self._config_delay, self.async_end_delay - ) + if self.config_delay: + await asyncio.sleep(self.config_delay) + self.config_delay = 0 + self.event_connected.set() async def async_setup(self) -> bool: """Set up pymodbus client.""" @@ -349,12 +347,6 @@ class ModbusHub: ) return True - @callback - def async_end_delay(self, args: Any) -> None: - """End startup delay.""" - self._async_cancel_listener = None - self._config_delay = 0 - async def async_restart(self) -> None: """Reconnect client.""" if self._client: @@ -364,9 +356,6 @@ class ModbusHub: async def async_close(self) -> None: """Disconnect client.""" - if self._async_cancel_listener: - self._async_cancel_listener() - self._async_cancel_listener = None if not self._connect_task.done(): self._connect_task.cancel() @@ -426,8 +415,6 @@ class ModbusHub: use_call: str, ) -> ModbusPDU | None: """Convert async to sync pymodbus call.""" - if self._config_delay: - return None async with self._lock: if not self._client: return None diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index a35cc95605d..f7bd4b13a1b 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -23,6 +23,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from homeassistant.util.hass_dict import HassKey from tests.common import async_fire_time_changed, mock_restore_cache @@ -121,6 +122,7 @@ def mock_pymodbus_fixture(do_exception, register_words): async def mock_modbus_fixture( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, check_config_loaded, config_addon, do_config, @@ -158,6 +160,15 @@ async def mock_modbus_fixture( result = await async_setup_component(hass, DOMAIN, config) assert result or not check_config_loaded await hass.async_block_till_done() + key = HassKey(DOMAIN) + if key not in hass.data: + return None + hub = hass.data[HassKey(DOMAIN)][TEST_MODBUS_NAME] + await hub.event_connected.wait() + assert hub.event_connected.is_set() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() return mock_pymodbus diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 3896d34146a..3816e9878cb 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -920,6 +920,9 @@ async def mock_modbus_read_pymodbus_fixture( freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL + 60)) async_fire_time_changed(hass) await hass.async_block_till_done() + freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL + 60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() return mock_pymodbus @@ -1088,11 +1091,11 @@ async def test_delay( start_time = dt_util.utcnow() assert await async_setup_component(hass, DOMAIN, config) is True await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert hass.states.get(entity_id).state in (STATE_UNKNOWN, STATE_UNAVAILABLE) time_sensor_active = start_time + timedelta(seconds=2) time_after_delay = start_time + timedelta(seconds=(set_delay)) - time_after_scan = start_time + timedelta(seconds=(set_delay + set_scan_interval)) + time_after_scan = time_after_delay + timedelta(seconds=(set_scan_interval)) time_stop = time_after_scan + timedelta(seconds=10) now = start_time while now < time_stop: @@ -1105,8 +1108,13 @@ async def test_delay( await hass.async_block_till_done() if now > time_sensor_active: if now <= time_after_delay: - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - elif now > time_after_scan: + assert hass.states.get(entity_id).state in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ) + if now <= time_after_delay + timedelta(seconds=2): + continue + if now > time_after_scan + timedelta(seconds=2): assert hass.states.get(entity_id).state == STATE_ON From 019c4ab874d036726a3acc77fc3fb820793b2b24 Mon Sep 17 00:00:00 2001 From: Foscam-wangzhengyu Date: Mon, 18 Aug 2025 23:34:00 +0800 Subject: [PATCH 1108/1113] Bump libpyfoscamcgi to 0.0.7 (#150829) --- homeassistant/components/foscam/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/foscam/manifest.json b/homeassistant/components/foscam/manifest.json index 9e6864cf1c6..87112199b0f 100644 --- a/homeassistant/components/foscam/manifest.json +++ b/homeassistant/components/foscam/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/foscam", "iot_class": "local_polling", "loggers": ["libpyfoscamcgi"], - "requirements": ["libpyfoscamcgi==0.0.6"] + "requirements": ["libpyfoscamcgi==0.0.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index cba0201941b..5cda5c89bc5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1343,7 +1343,7 @@ lektricowifi==0.1 letpot==0.6.1 # homeassistant.components.foscam -libpyfoscamcgi==0.0.6 +libpyfoscamcgi==0.0.7 # homeassistant.components.vivotek libpyvivotek==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b9fd9d9e5f5..4cc418ea4d1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1162,7 +1162,7 @@ lektricowifi==0.1 letpot==0.6.1 # homeassistant.components.foscam -libpyfoscamcgi==0.0.6 +libpyfoscamcgi==0.0.7 # homeassistant.components.mikrotik librouteros==3.2.0 From ab4aeb65f2df3f6f673fd0edc29ee3d234aafd15 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Mon, 18 Aug 2025 16:35:37 +0100 Subject: [PATCH 1109/1113] Bump mastodon.py to 2.1.0 and change quality scale (#150836) --- homeassistant/components/mastodon/manifest.json | 3 ++- homeassistant/components/mastodon/quality_scale.yaml | 5 +---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/quality_scale.py | 1 - tests/components/mastodon/snapshots/test_diagnostics.ambr | 1 + 6 files changed, 6 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/mastodon/manifest.json b/homeassistant/components/mastodon/manifest.json index d7b21ad3a0c..99bb9801183 100644 --- a/homeassistant/components/mastodon/manifest.json +++ b/homeassistant/components/mastodon/manifest.json @@ -7,5 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["mastodon"], - "requirements": ["Mastodon.py==2.0.1"] + "quality_scale": "bronze", + "requirements": ["Mastodon.py==2.1.0"] } diff --git a/homeassistant/components/mastodon/quality_scale.yaml b/homeassistant/components/mastodon/quality_scale.yaml index c5a928bac59..ff3d4ad3db0 100644 --- a/homeassistant/components/mastodon/quality_scale.yaml +++ b/homeassistant/components/mastodon/quality_scale.yaml @@ -6,10 +6,7 @@ rules: common-modules: done config-flow-test-coverage: done config-flow: done - dependency-transparency: - status: todo - comment: | - Mastodon.py does not have CI build/publish. + dependency-transparency: done docs-actions: done docs-high-level-description: done docs-installation-instructions: done diff --git a/requirements_all.txt b/requirements_all.txt index 5cda5c89bc5..ddd4d61cd5f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -22,7 +22,7 @@ HAP-python==4.9.2 HATasmota==0.10.0 # homeassistant.components.mastodon -Mastodon.py==2.0.1 +Mastodon.py==2.1.0 # homeassistant.components.playstation_network PSNAWP==3.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4cc418ea4d1..baea0aa8098 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -22,7 +22,7 @@ HAP-python==4.9.2 HATasmota==0.10.0 # homeassistant.components.mastodon -Mastodon.py==2.0.1 +Mastodon.py==2.1.0 # homeassistant.components.playstation_network PSNAWP==3.0.0 diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 97d510ce4ca..6501aee0733 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1651,7 +1651,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "manual", "manual_mqtt", "map", - "mastodon", "marytts", "matrix", "matter", diff --git a/tests/components/mastodon/snapshots/test_diagnostics.ambr b/tests/components/mastodon/snapshots/test_diagnostics.ambr index 9198410f066..ec9da1836bc 100644 --- a/tests/components/mastodon/snapshots/test_diagnostics.ambr +++ b/tests/components/mastodon/snapshots/test_diagnostics.ambr @@ -45,6 +45,7 @@ 'limited': None, 'locked': False, 'memorial': None, + 'moved': None, 'moved_to_account': None, 'mute_expires_at': None, 'noindex': False, From f6d23b9b34664df8a2a002ed9057a72a3669a7bc Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 18 Aug 2025 17:44:31 +0200 Subject: [PATCH 1110/1113] Check for forbidden files in dependencies with hassfest (#150772) --- script/hassfest/requirements.py | 42 +++++++++++---- tests/hassfest/test_requirements.py | 80 ++++++++++++++++++++++++----- 2 files changed, 97 insertions(+), 25 deletions(-) diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index d8aa383cfec..a2d305f76ef 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -11,7 +11,7 @@ import os import re import subprocess import sys -from typing import Any +from typing import Any, TypedDict from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from tqdm import tqdm @@ -295,6 +295,9 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { }, } +FORBIDDEN_FILE_NAMES: set[str] = { + "py.typed", # should be placed inside a package +} FORBIDDEN_PACKAGE_NAMES: set[str] = { "doc", "docs", @@ -364,7 +367,15 @@ PYTHON_VERSION_CHECK_EXCEPTIONS: dict[str, dict[str, set[str]]] = { }, } -_packages_checked_files_cache: dict[str, set[str]] = {} + +class _PackageFilesCheckResult(TypedDict): + """Data structure to store results of package files check.""" + + top_level: set[str] + file_names: set[str] + + +_packages_checked_files_cache: dict[str, _PackageFilesCheckResult] = {} def validate(integrations: dict[str, Integration], config: Config) -> None: @@ -733,24 +744,33 @@ def check_dependency_files( pkg: str, package_exceptions: Collection[str], ) -> bool: - """Check dependency files for forbidden package names.""" + """Check dependency files for forbidden files and forbidden package names.""" if (results := _packages_checked_files_cache.get(pkg)) is None: top_level: set[str] = set() + file_names: set[str] = set() for file in files(pkg) or (): - top = file.parts[0].lower() - if top.endswith((".dist-info", ".py")): - continue - top_level.add(top) - results = FORBIDDEN_PACKAGE_NAMES & top_level + if not (top := file.parts[0].lower()).endswith((".dist-info", ".py")): + top_level.add(top) + if (name := str(file)).lower() in FORBIDDEN_FILE_NAMES: + file_names.add(name) + results = _PackageFilesCheckResult( + top_level=FORBIDDEN_PACKAGE_NAMES & top_level, + file_names=file_names, + ) _packages_checked_files_cache[pkg] = results - if not results: + if not (results["top_level"] or results["file_names"]): return True - for dir_name in results: + for dir_name in results["top_level"]: integration.add_warning_or_error( pkg in package_exceptions, "requirements", - f"Package {pkg} has a forbidden top level directory {dir_name} in {package}", + f"Package {pkg} has a forbidden top level directory '{dir_name}' in {package}", + ) + for file_name in results["file_names"]: + integration.add_error( + "requirements", + f"Package {pkg} has a forbidden file '{file_name}' in {package}", ) return False diff --git a/tests/hassfest/test_requirements.py b/tests/hassfest/test_requirements.py index 944b06d3c90..329357bfca4 100644 --- a/tests/hassfest/test_requirements.py +++ b/tests/hassfest/test_requirements.py @@ -170,8 +170,8 @@ def test_dependency_version_range_prepare_update( @pytest.mark.usefixtures("mock_forbidden_package_names") -def test_check_dependency_files(integration: Integration) -> None: - """Test dependency files check for forbidden package names is working correctly.""" +def test_check_dependency_package_names(integration: Integration) -> None: + """Test dependency package names check for forbidden package names is working correctly.""" package = "homeassistant" pkg = "my_package" @@ -190,17 +190,15 @@ def test_check_dependency_files(integration: Integration) -> None: ): assert not _packages_checked_files_cache assert check_dependency_files(integration, package, pkg, ()) is False - assert _packages_checked_files_cache[pkg] == {"tests", "test"} + assert _packages_checked_files_cache[pkg]["top_level"] == {"tests", "test"} assert len(integration.errors) == 2 assert ( - f"Package {pkg} has a forbidden top level directory tests in {package}" - in x.error - for x in integration.errors + f"Package {pkg} has a forbidden top level directory 'tests' in {package}" + in [x.error for x in integration.errors] ) assert ( - f"Package {pkg} has a forbidden top level directory test in {package}" - in x.error - for x in integration.errors + f"Package {pkg} has a forbidden top level directory 'test' in {package}" + in [x.error for x in integration.errors] ) integration.errors.clear() @@ -227,13 +225,12 @@ def test_check_dependency_files(integration: Integration) -> None: check_dependency_files(integration, package, pkg, package_exceptions={pkg}) is False ) - assert _packages_checked_files_cache[pkg] == {"tests"} + assert _packages_checked_files_cache[pkg]["top_level"] == {"tests"} assert len(integration.errors) == 0 assert len(integration.warnings) == 1 assert ( - f"Package {pkg} has a forbidden top level directory tests in {package}" - in x.error - for x in integration.warnings + f"Package {pkg} has a forbidden top level directory 'tests' in {package}" + in [x.error for x in integration.warnings] ) integration.warnings.clear() @@ -260,7 +257,62 @@ def test_check_dependency_files(integration: Integration) -> None: ): assert not _packages_checked_files_cache assert check_dependency_files(integration, package, pkg, ()) is True - assert _packages_checked_files_cache[pkg] == set() + assert _packages_checked_files_cache[pkg]["top_level"] == set() + assert len(integration.errors) == 0 + + # Repeated call should use cache + assert check_dependency_files(integration, package, pkg, ()) is True + assert mock_files.call_count == 1 + assert len(integration.errors) == 0 + + +def test_check_dependency_file_names(integration: Integration) -> None: + """Test dependency file name check for forbidden files is working correctly.""" + package = "homeassistant" + pkg = "my_package" + + # Forbidden file: 'py.typed' at top level + pkg_files = [ + PackagePath("py.typed"), + PackagePath("my_package.py"), + PackagePath("my_package-1.0.0.dist-info/METADATA"), + ] + with ( + patch( + "script.hassfest.requirements.files", return_value=pkg_files + ) as mock_files, + patch.dict(_packages_checked_files_cache, {}, clear=True), + ): + assert not _packages_checked_files_cache + assert check_dependency_files(integration, package, pkg, ()) is False + assert _packages_checked_files_cache[pkg]["file_names"] == {"py.typed"} + assert len(integration.errors) == 1 + assert f"Package {pkg} has a forbidden file 'py.typed' in {package}" in [ + x.error for x in integration.errors + ] + integration.errors.clear() + + # Repeated call should use cache + assert check_dependency_files(integration, package, pkg, ()) is False + assert mock_files.call_count == 1 + assert len(integration.errors) == 1 + integration.errors.clear() + + # All good + pkg_files = [ + PackagePath("my_package/__init__.py"), + PackagePath("my_package/py.typed"), + PackagePath("my_package.dist-info/METADATA"), + ] + with ( + patch( + "script.hassfest.requirements.files", return_value=pkg_files + ) as mock_files, + patch.dict(_packages_checked_files_cache, {}, clear=True), + ): + assert not _packages_checked_files_cache + assert check_dependency_files(integration, package, pkg, ()) is True + assert _packages_checked_files_cache[pkg]["file_names"] == set() assert len(integration.errors) == 0 # Repeated call should use cache From 40feefc0faa263b5fd82099bb4cf3fc091910e7d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 18 Aug 2025 21:28:58 +0200 Subject: [PATCH 1111/1113] Cleanup sw_version in Renault (#150844) --- homeassistant/components/renault/renault_hub.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py index 1f883435dee..5e14328eb7c 100644 --- a/homeassistant/components/renault/renault_hub.py +++ b/homeassistant/components/renault/renault_hub.py @@ -156,6 +156,7 @@ class RenaultHub: name=vehicle.device_info[ATTR_NAME], model=vehicle.device_info[ATTR_MODEL], model_id=vehicle.device_info[ATTR_MODEL_ID], + sw_version=None, # cleanup from PR #125399 ) self._vehicles[vehicle_link.vin] = vehicle From c7001dcfc4e4af71fc3c39827206d31a73d091e2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 18 Aug 2025 21:40:43 +0200 Subject: [PATCH 1112/1113] Bump holidays to 0.79 (#150857) --- 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 dde50da1af3..5ea0d217f14 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.78", "babel==2.15.0"] + "requirements": ["holidays==0.79", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index d2309702728..0e336632b2e 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.78"] + "requirements": ["holidays==0.79"] } diff --git a/requirements_all.txt b/requirements_all.txt index ddd4d61cd5f..ffd754f994b 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.78 +holidays==0.79 # homeassistant.components.frontend home-assistant-frontend==20250811.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index baea0aa8098..a9af4dbb605 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.78 +holidays==0.79 # homeassistant.components.frontend home-assistant-frontend==20250811.0 From 15505cdd56f860e21155474f8bd92c044411cb69 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 18 Aug 2025 22:14:52 +0200 Subject: [PATCH 1113/1113] Handle Z-Wave RssiErrorReceived (#150846) --- homeassistant/components/zwave_js/sensor.py | 38 +++-- tests/components/zwave_js/test_sensor.py | 177 ++++++++++++++++++++ 2 files changed, 204 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 2efb8c8e67c..23b906a9d16 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -4,15 +4,15 @@ from __future__ import annotations from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import Any +from typing import Any, cast import voluptuous as vol -from zwave_js_server.const import CommandClass +from zwave_js_server.const import CommandClass, RssiError from zwave_js_server.const.command_class.meter import ( RESET_METER_OPTION_TARGET_VALUE, RESET_METER_OPTION_TYPE, ) -from zwave_js_server.exceptions import BaseZwaveJSServerError +from zwave_js_server.exceptions import BaseZwaveJSServerError, RssiErrorReceived from zwave_js_server.model.controller import Controller from zwave_js_server.model.controller.statistics import ControllerStatistics from zwave_js_server.model.driver import Driver @@ -1049,7 +1049,7 @@ class ZWaveStatisticsSensor(SensorEntity): self, config_entry: ZwaveJSConfigEntry, driver: Driver, - statistics_src: ZwaveNode | Controller, + statistics_src: Controller | ZwaveNode, description: ZWaveJSStatisticsSensorEntityDescription, ) -> None: """Initialize a Z-Wave statistics entity.""" @@ -1080,13 +1080,31 @@ class ZWaveStatisticsSensor(SensorEntity): ) @callback - def statistics_updated(self, event_data: dict) -> None: + def _statistics_updated(self, event_data: dict) -> None: """Call when statistics updated event is received.""" - self._attr_native_value = self.entity_description.convert( - event_data["statistics_updated"], self.entity_description.key + statistics = cast( + ControllerStatistics | NodeStatistics, event_data["statistics_updated"] ) + self._set_statistics(statistics) self.async_write_ha_state() + @callback + def _set_statistics( + self, statistics: ControllerStatistics | NodeStatistics + ) -> None: + """Set updated statistics.""" + try: + self._attr_native_value = self.entity_description.convert( + statistics, self.entity_description.key + ) + except RssiErrorReceived as err: + if err.error is RssiError.NOT_AVAILABLE: + self._attr_available = False + return + self._attr_native_value = None + # Reset available state. + self._attr_available = True + async def async_added_to_hass(self) -> None: """Call when entity is added.""" self.async_on_remove( @@ -1104,10 +1122,8 @@ class ZWaveStatisticsSensor(SensorEntity): ) ) self.async_on_remove( - self.statistics_src.on("statistics updated", self.statistics_updated) + self.statistics_src.on("statistics updated", self._statistics_updated) ) # Set initial state - self._attr_native_value = self.entity_description.convert( - self.statistics_src.statistics, self.entity_description.key - ) + self._set_statistics(self.statistics_src.statistics) diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index c7b41449d43..e287c9e988f 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -1045,6 +1045,183 @@ async def test_last_seen_statistics_sensors( assert state.state == "2024-01-01T12:00:00+00:00" +async def test_rssi_sensor_error( + hass: HomeAssistant, + zp3111: Node, + integration: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test rssi sensor error.""" + entity_id = "sensor.4_in_1_sensor_signal_strength" + + entity_registry.async_update_entity(entity_id, disabled_by=None) + + # reload integration and check if entity is correctly there + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "unknown" + + # Fire statistics updated event for node + event = Event( + "statistics updated", + { + "source": "node", + "event": "statistics updated", + "nodeId": zp3111.node_id, + "statistics": { + "commandsTX": 1, + "commandsRX": 2, + "commandsDroppedTX": 3, + "commandsDroppedRX": 4, + "timeoutResponse": 5, + "rtt": 6, + "rssi": 7, # baseline + "lwr": { + "protocolDataRate": 1, + "rssi": 1, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "nlwr": { + "protocolDataRate": 2, + "rssi": 2, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "lastSeen": "2024-01-01T00:00:00+0000", + }, + }, + ) + zp3111.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "7" + + event = Event( + "statistics updated", + { + "source": "node", + "event": "statistics updated", + "nodeId": zp3111.node_id, + "statistics": { + "commandsTX": 1, + "commandsRX": 2, + "commandsDroppedTX": 3, + "commandsDroppedRX": 4, + "timeoutResponse": 5, + "rtt": 6, + "rssi": 125, # no signal detected + "lwr": { + "protocolDataRate": 1, + "rssi": 1, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "nlwr": { + "protocolDataRate": 2, + "rssi": 2, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "lastSeen": "2024-01-01T00:00:00+0000", + }, + }, + ) + zp3111.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "unknown" + + event = Event( + "statistics updated", + { + "source": "node", + "event": "statistics updated", + "nodeId": zp3111.node_id, + "statistics": { + "commandsTX": 1, + "commandsRX": 2, + "commandsDroppedTX": 3, + "commandsDroppedRX": 4, + "timeoutResponse": 5, + "rtt": 6, + "rssi": 127, # not available + "lwr": { + "protocolDataRate": 1, + "rssi": 1, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "nlwr": { + "protocolDataRate": 2, + "rssi": 2, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "lastSeen": "2024-01-01T00:00:00+0000", + }, + }, + ) + zp3111.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "unavailable" + + event = Event( + "statistics updated", + { + "source": "node", + "event": "statistics updated", + "nodeId": zp3111.node_id, + "statistics": { + "commandsTX": 1, + "commandsRX": 2, + "commandsDroppedTX": 3, + "commandsDroppedRX": 4, + "timeoutResponse": 5, + "rtt": 6, + "rssi": 126, # receiver saturated + "lwr": { + "protocolDataRate": 1, + "rssi": 1, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "nlwr": { + "protocolDataRate": 2, + "rssi": 2, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "lastSeen": "2024-01-01T00:00:00+0000", + }, + }, + ) + zp3111.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "unknown" + + ENERGY_PRODUCTION_ENTITY_MAP = { "energy_production_power": { "state": 1.23,