From 7f7bd5a97f4384f6636455fd14460e7842f68a8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Sep 2025 01:56:20 -0600 Subject: [PATCH 001/189] Bump aioesphomeapi to 41.5.0 (#152730) --- 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 dd5bef1bc823..d9245dc4339f 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==41.4.0", + "aioesphomeapi==41.5.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.3.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 013fc122c02a..9d84cd3db52e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.4.0 +aioesphomeapi==41.5.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a618edc2c475..69d909c23df6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.4.0 +aioesphomeapi==41.5.0 # homeassistant.components.flo aioflo==2021.11.0 From de42ac14acbead886b730489cb86915109c8c812 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 22 Sep 2025 09:56:52 +0200 Subject: [PATCH 002/189] Drop unused hass argument from internal helper (#152733) --- homeassistant/helpers/service.py | 10 ++++------ tests/components/api/test_init.py | 2 +- tests/components/websocket_api/test_commands.py | 2 +- tests/helpers/test_service.py | 6 +++--- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 734d2a4dfa07..c5379f607f6f 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -492,7 +492,7 @@ async def async_extract_config_entry_ids( return config_entry_ids -def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE: +def _load_services_file(integration: Integration) -> JSON_TYPE: """Load services file for an integration.""" try: return cast( @@ -515,12 +515,10 @@ def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_T return {} -def _load_services_files( - hass: HomeAssistant, integrations: Iterable[Integration] -) -> dict[str, JSON_TYPE]: +def _load_services_files(integrations: Iterable[Integration]) -> dict[str, JSON_TYPE]: """Load service files for multiple integrations.""" return { - integration.domain: _load_services_file(hass, integration) + integration.domain: _load_services_file(integration) for integration in integrations } @@ -586,7 +584,7 @@ async def async_get_all_descriptions( if integrations: loaded = await hass.async_add_executor_job( - _load_services_files, hass, integrations + _load_services_files, integrations ) # Load translations for all service domains diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 382b88b89ea8..c000c1c31814 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -338,7 +338,7 @@ async def test_api_get_services( assert data == snapshot # Set up an integration with legacy translations in services.yaml - def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE: + def _load_services_file(integration: Integration) -> JSON_TYPE: return { "set_default_level": { "description": "Translated description", diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index bffb2959b31e..253b77b377b2 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -718,7 +718,7 @@ async def test_get_services( assert hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] is old_cache # Set up an integration with legacy translations in services.yaml - def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE: + def _load_services_file(integration: Integration) -> JSON_TYPE: return { "set_default_level": { "description": "Translated description", diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 8a1329c21bf7..7285d5c7df8a 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -837,7 +837,7 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: # Test we only load services.yaml for integrations with services.yaml # And system_health has no services - assert proxy_load_services_files.mock_calls[0][1][1] == unordered( + assert proxy_load_services_files.mock_calls[0][1][0] == unordered( [ await async_get_integration(hass, DOMAIN_GROUP), ] @@ -990,7 +990,7 @@ async def test_async_get_all_descriptions_dot_keys(hass: HomeAssistant) -> None: descriptions = await service.async_get_all_descriptions(hass) mock_load_yaml.assert_called_once_with("services.yaml", None) - assert proxy_load_services_files.mock_calls[0][1][1] == unordered( + assert proxy_load_services_files.mock_calls[0][1][0] == unordered( [ await async_get_integration(hass, domain), ] @@ -1085,7 +1085,7 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: descriptions = await service.async_get_all_descriptions(hass) mock_load_yaml.assert_called_once_with("services.yaml", None) - assert proxy_load_services_files.mock_calls[0][1][1] == unordered( + assert proxy_load_services_files.mock_calls[0][1][0] == unordered( [ await async_get_integration(hass, domain), ] From ca1c366f4f71833c8ee9319b22f96f83289fde96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 22 Sep 2025 08:57:16 +0100 Subject: [PATCH 003/189] Remove unused var from llm helper (#152724) --- homeassistant/helpers/llm.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 9a019551c1ec..1eb30fe75121 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -656,7 +656,6 @@ def _get_exposed_entities( if not async_should_expose(hass, assistant, state.entity_id): continue - description: str | None = None entity_entry = entity_registry.async_get(state.entity_id) names = [state.name] area_names = [] @@ -692,9 +691,6 @@ def _get_exposed_entities( if (parsed_utc := dt_util.parse_datetime(state.state)) is not None: info["state"] = dt_util.as_local(parsed_utc).isoformat() - if description: - info["description"] = description - if area_names: info["areas"] = ", ".join(area_names) From 4b7746ab5165007125151f2fc27e12ba3c4910a8 Mon Sep 17 00:00:00 2001 From: Joshua Leaper Date: Mon, 22 Sep 2025 17:31:04 +0930 Subject: [PATCH 004/189] Bump nessclient to 1.3.1 (#152700) --- homeassistant/components/ness_alarm/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ness_alarm/manifest.json b/homeassistant/components/ness_alarm/manifest.json index 79227e8564ba..0b032fc24f6b 100644 --- a/homeassistant/components/ness_alarm/manifest.json +++ b/homeassistant/components/ness_alarm/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["nessclient"], "quality_scale": "legacy", - "requirements": ["nessclient==1.2.0"] + "requirements": ["nessclient==1.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9d84cd3db52e..45285b21df02 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1503,7 +1503,7 @@ nad-receiver==0.3.0 ndms2-client==0.1.2 # homeassistant.components.ness_alarm -nessclient==1.2.0 +nessclient==1.3.1 # homeassistant.components.netdata netdata==1.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 69d909c23df6..2ca7601da1fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1292,7 +1292,7 @@ myuplink==0.7.0 ndms2-client==0.1.2 # homeassistant.components.ness_alarm -nessclient==1.2.0 +nessclient==1.3.1 # homeassistant.components.nmap_tracker netmap==0.7.0.2 From 286b2500bde64a6624cc2ce4e7cca2e98e6f7836 Mon Sep 17 00:00:00 2001 From: Lukas <12813107+lmaertin@users.noreply.github.com> Date: Mon, 22 Sep 2025 11:31:40 +0200 Subject: [PATCH 005/189] Pooldose: Add Dhcp discovery (#152253) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joostlek --- .../components/pooldose/config_flow.py | 120 +++++++++++------- .../components/pooldose/manifest.json | 5 + .../components/pooldose/quality_scale.yaml | 8 +- .../components/pooldose/strings.json | 9 +- homeassistant/generated/dhcp.py | 4 + .../pooldose/fixtures/deviceinfo.json | 2 +- tests/components/pooldose/test_config_flow.py | 120 +++++++++++++++++- 7 files changed, 212 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/pooldose/config_flow.py b/homeassistant/components/pooldose/config_flow.py index e4bf114a9364..36cd93b7515f 100644 --- a/homeassistant/components/pooldose/config_flow.py +++ b/homeassistant/components/pooldose/config_flow.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN @@ -25,10 +26,77 @@ SCHEMA_DEVICE = vol.Schema( class PooldoseConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a config flow for Seko Pooldose.""" + """Config flow for the Pooldose integration including DHCP discovery.""" VERSION = 1 + def __init__(self) -> None: + """Initialize the config flow and store the discovered IP address.""" + super().__init__() + self._discovered_ip: str | None = None + + async def _validate_host( + self, host: str + ) -> tuple[str | None, dict[str, str] | None, dict[str, str] | None]: + """Validate the host and return (serial_number, api_versions, errors).""" + client = PooldoseClient(host) + client_status = await client.connect() + if client_status == RequestStatus.HOST_UNREACHABLE: + return None, None, {"base": "cannot_connect"} + if client_status == RequestStatus.PARAMS_FETCH_FAILED: + return None, None, {"base": "params_fetch_failed"} + if client_status != RequestStatus.SUCCESS: + return None, None, {"base": "cannot_connect"} + + api_status, api_versions = client.check_apiversion_supported() + if api_status == RequestStatus.NO_DATA: + return None, None, {"base": "api_not_set"} + if api_status == RequestStatus.API_VERSION_UNSUPPORTED: + return None, api_versions, {"base": "api_not_supported"} + + device_info = client.device_info + if not device_info: + return None, None, {"base": "no_device_info"} + serial_number = device_info.get("SERIAL_NUMBER") + if not serial_number: + return None, None, {"base": "no_serial_number"} + + return serial_number, None, None + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery: validate device and update IP if needed.""" + serial_number, _, _ = await self._validate_host(discovery_info.ip) + if not serial_number: + return self.async_abort(reason="no_serial_number") + + await self.async_set_unique_id(serial_number) + + # Conditionally update IP and abort if entry exists + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + + # Continue with new device flow + self._discovered_ip = discovery_info.ip + return self.async_show_form( + step_id="dhcp_confirm", + description_placeholders={ + "ip": discovery_info.ip, + "mac": discovery_info.macaddress, + "name": f"PoolDose {serial_number}", + }, + ) + + async def async_step_dhcp_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Create the entry after the confirmation dialog.""" + discovered_ip = self._discovered_ip + return self.async_create_entry( + title=f"PoolDose {self.unique_id}", + data={CONF_HOST: discovered_ip}, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -40,58 +108,16 @@ class PooldoseConfigFlow(ConfigFlow, domain=DOMAIN): ) host = user_input[CONF_HOST] - client = PooldoseClient(host) - client_status = await client.connect() - if client_status == RequestStatus.HOST_UNREACHABLE: + serial_number, api_versions, errors = await self._validate_host(host) + if errors: return self.async_show_form( step_id="user", data_schema=SCHEMA_DEVICE, - errors={"base": "cannot_connect"}, - ) - if client_status == RequestStatus.PARAMS_FETCH_FAILED: - return self.async_show_form( - step_id="user", - data_schema=SCHEMA_DEVICE, - errors={"base": "params_fetch_failed"}, - ) - if client_status != RequestStatus.SUCCESS: - return self.async_show_form( - step_id="user", - data_schema=SCHEMA_DEVICE, - errors={"base": "cannot_connect"}, - ) - - api_status, api_versions = client.check_apiversion_supported() - if api_status == RequestStatus.NO_DATA: - return self.async_show_form( - step_id="user", - data_schema=SCHEMA_DEVICE, - errors={"base": "api_not_set"}, - ) - if api_status == RequestStatus.API_VERSION_UNSUPPORTED: - return self.async_show_form( - step_id="user", - data_schema=SCHEMA_DEVICE, - errors={"base": "api_not_supported"}, + errors=errors, description_placeholders=api_versions, ) - device_info = client.device_info - if not device_info: - return self.async_show_form( - step_id="user", - data_schema=SCHEMA_DEVICE, - errors={"base": "no_device_info"}, - ) - serial_number = device_info.get("SERIAL_NUMBER") - if not serial_number: - return self.async_show_form( - step_id="user", - data_schema=SCHEMA_DEVICE, - errors={"base": "no_serial_number"}, - ) - - await self.async_set_unique_id(serial_number) + await self.async_set_unique_id(serial_number, raise_on_progress=False) self._abort_if_unique_id_configured() return self.async_create_entry( title=f"PoolDose {serial_number}", diff --git a/homeassistant/components/pooldose/manifest.json b/homeassistant/components/pooldose/manifest.json index 8bcbb18737cd..5328edce1082 100644 --- a/homeassistant/components/pooldose/manifest.json +++ b/homeassistant/components/pooldose/manifest.json @@ -3,6 +3,11 @@ "name": "SEKO PoolDose", "codeowners": ["@lmaertin"], "config_flow": true, + "dhcp": [ + { + "hostname": "kommspot" + } + ], "documentation": "https://www.home-assistant.io/integrations/pooldose", "iot_class": "local_polling", "quality_scale": "bronze", diff --git a/homeassistant/components/pooldose/quality_scale.yaml b/homeassistant/components/pooldose/quality_scale.yaml index dc3c2221d73c..3c685e8c511e 100644 --- a/homeassistant/components/pooldose/quality_scale.yaml +++ b/homeassistant/components/pooldose/quality_scale.yaml @@ -44,12 +44,8 @@ rules: # Gold devices: done diagnostics: todo - discovery-update-info: - status: todo - comment: DHCP discovery is possible - discovery: - status: todo - comment: DHCP discovery is possible + discovery-update-info: done + discovery: done docs-data-update: done docs-examples: todo docs-known-limitations: todo diff --git a/homeassistant/components/pooldose/strings.json b/homeassistant/components/pooldose/strings.json index 1a9dbbf106f3..59e2ee7a950c 100644 --- a/homeassistant/components/pooldose/strings.json +++ b/homeassistant/components/pooldose/strings.json @@ -10,6 +10,10 @@ "data_description": { "host": "IP address or hostname of your device" } + }, + "dhcp_confirm": { + "title": "Confirm DHCP discovered PoolDose device", + "description": "A PoolDose device was found on your network at {ip} with MAC address {mac}.\n\nDo you want to add {name} to Home Assistant?" } }, "error": { @@ -22,7 +26,10 @@ "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%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_device_info": "Unable to retrieve device information", + "no_serial_number": "No serial number found on the device" } }, "entity": { diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index ab95b106551b..e744f42b5412 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -563,6 +563,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "playstation_network", "macaddress": "84E657*", }, + { + "domain": "pooldose", + "hostname": "kommspot", + }, { "domain": "powerwall", "hostname": "1118431-*", diff --git a/tests/components/pooldose/fixtures/deviceinfo.json b/tests/components/pooldose/fixtures/deviceinfo.json index 528be8757e66..69ac3ba0a0a8 100644 --- a/tests/components/pooldose/fixtures/deviceinfo.json +++ b/tests/components/pooldose/fixtures/deviceinfo.json @@ -10,6 +10,6 @@ "SW_VERSION": "2.10", "API_VERSION": "v1/", "FW_CODE": "539187", - "MAC": "AA:BB:CC:DD:EE:FF", + "MAC": "", "IP": "192.168.1.100" } diff --git a/tests/components/pooldose/test_config_flow.py b/tests/components/pooldose/test_config_flow.py index 6229526dd9ac..777f2843bba2 100644 --- a/tests/components/pooldose/test_config_flow.py +++ b/tests/components/pooldose/test_config_flow.py @@ -6,10 +6,11 @@ from unittest.mock import AsyncMock import pytest from homeassistant.components.pooldose.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .conftest import RequestStatus @@ -237,3 +238,120 @@ async def test_duplicate_entry_aborts( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test the full DHCP config flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.0.123", hostname="kommspot", macaddress="a4e57caabbcc" + ), + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "dhcp_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "PoolDose TEST123456789" + assert result["data"] == {CONF_HOST: "192.168.0.123"} + assert result["result"].unique_id == "TEST123456789" + + +async def test_dhcp_no_serial_number( + hass: HomeAssistant, mock_pooldose_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test that the DHCP flow aborts if no serial number is found.""" + mock_pooldose_client.device_info = {"NAME": "Pool Device", "MODEL": "POOL DOSE"} + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.0.123", hostname="kommspot", macaddress="a4e57caabbcc" + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_serial_number" + + +@pytest.mark.parametrize( + ("client_status"), + [ + (RequestStatus.HOST_UNREACHABLE), + (RequestStatus.PARAMS_FETCH_FAILED), + (RequestStatus.UNKNOWN_ERROR), + ], +) +async def test_dhcp_connection_errors( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, + mock_setup_entry: AsyncMock, + client_status: str, +) -> None: + """Test that the DHCP flow aborts on connection errors.""" + mock_pooldose_client.connect.return_value = client_status + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.0.123", hostname="kommspot", macaddress="a4e57caabbcc" + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_serial_number" + + +@pytest.mark.parametrize( + "api_status", + [ + RequestStatus.NO_DATA, + RequestStatus.API_VERSION_UNSUPPORTED, + ], +) +async def test_dhcp_api_errors( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, + mock_setup_entry: AsyncMock, + api_status: str, +) -> None: + """Test that the DHCP flow aborts on API errors.""" + mock_pooldose_client.check_apiversion_supported.return_value = (api_status, {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.0.123", hostname="kommspot", macaddress="a4e57caabbcc" + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_serial_number" + + +async def test_dhcp_updates_host( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_setup_entry: AsyncMock +) -> None: + """Test that DHCP discovery updates the host if it has changed.""" + mock_config_entry.add_to_hass(hass) + + # Verify initial host IP + assert mock_config_entry.data[CONF_HOST] == "192.168.1.100" + + # Simulate DHCP discovery event with different IP + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.0.123", hostname="kommspot", macaddress="a4e57caabbcc" + ), + ) + + # Verify flow aborts as device is already configured + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_config_entry.data[CONF_HOST] == "192.168.0.123" From 844b97bd32ba9067f11f4bc9977911142f4147ca Mon Sep 17 00:00:00 2001 From: Tom Matheussen Date: Mon, 22 Sep 2025 11:38:26 +0200 Subject: [PATCH 006/189] Add Satel Integra diagnostics (#152621) Co-authored-by: Joost Lekkerkerker --- .../components/satel_integra/diagnostics.py | 26 ++ tests/components/satel_integra/__init__.py | 67 ++++ tests/components/satel_integra/conftest.py | 45 ++- .../snapshots/test_diagnostics.ambr | 57 +++ .../satel_integra/test_config_flow.py | 368 +++++------------- .../satel_integra/test_diagnostics.py | 31 ++ 6 files changed, 309 insertions(+), 285 deletions(-) create mode 100644 homeassistant/components/satel_integra/diagnostics.py create mode 100644 tests/components/satel_integra/snapshots/test_diagnostics.ambr create mode 100644 tests/components/satel_integra/test_diagnostics.py diff --git a/homeassistant/components/satel_integra/diagnostics.py b/homeassistant/components/satel_integra/diagnostics.py new file mode 100644 index 000000000000..93e9bd104ee6 --- /dev/null +++ b/homeassistant/components/satel_integra/diagnostics.py @@ -0,0 +1,26 @@ +"""Diagnostics support for Satel Integra.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_CODE +from homeassistant.core import HomeAssistant + +TO_REDACT = {CONF_CODE} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for the config entry.""" + diag: dict[str, Any] = {} + + diag["config_entry_data"] = dict(entry.data) + diag["config_entry_options"] = async_redact_data(entry.options, TO_REDACT) + + diag["subentries"] = dict(entry.subentries) + + return diag diff --git a/tests/components/satel_integra/__init__.py b/tests/components/satel_integra/__init__.py index 561eec238afb..97b8b4be4938 100644 --- a/tests/components/satel_integra/__init__.py +++ b/tests/components/satel_integra/__init__.py @@ -1 +1,68 @@ """The tests for Satel Integra integration.""" + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.satel_integra import ( + CONF_ARM_HOME_MODE, + CONF_OUTPUT_NUMBER, + CONF_PARTITION_NUMBER, + CONF_SWITCHABLE_OUTPUT_NUMBER, + CONF_ZONE_NUMBER, + CONF_ZONE_TYPE, + SUBENTRY_TYPE_OUTPUT, + SUBENTRY_TYPE_PARTITION, + SUBENTRY_TYPE_SWITCHABLE_OUTPUT, + SUBENTRY_TYPE_ZONE, +) +from homeassistant.components.satel_integra.const import DEFAULT_PORT +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_CODE, CONF_HOST, CONF_NAME, CONF_PORT + +MOCK_CONFIG_DATA = {CONF_HOST: "192.168.0.2", CONF_PORT: DEFAULT_PORT} +MOCK_CONFIG_OPTIONS = {CONF_CODE: "1234"} + +MOCK_PARTITION_SUBENTRY = ConfigSubentry( + subentry_type=SUBENTRY_TYPE_PARTITION, + subentry_id="ID_PARTITION", + unique_id="partition_1", + title="Home", + data={ + CONF_NAME: "Home", + CONF_ARM_HOME_MODE: 1, + CONF_PARTITION_NUMBER: 1, + }, +) + +MOCK_ZONE_SUBENTRY = ConfigSubentry( + subentry_type=SUBENTRY_TYPE_ZONE, + subentry_id="ID_ZONE", + unique_id="zone_1", + title="Zone 1", + data={ + CONF_NAME: "Zone 1", + CONF_ZONE_TYPE: BinarySensorDeviceClass.MOTION, + CONF_ZONE_NUMBER: 1, + }, +) + +MOCK_OUTPUT_SUBENTRY = ConfigSubentry( + subentry_type=SUBENTRY_TYPE_OUTPUT, + subentry_id="ID_OUTPUT", + unique_id="output_1", + title="Output 1", + data={ + CONF_NAME: "Output 1", + CONF_ZONE_TYPE: BinarySensorDeviceClass.SAFETY, + CONF_OUTPUT_NUMBER: 1, + }, +) + +MOCK_SWITCHABLE_OUTPUT_SUBENTRY = ConfigSubentry( + subentry_type=SUBENTRY_TYPE_SWITCHABLE_OUTPUT, + subentry_id="ID_SWITCHABLE_OUTPUT", + unique_id="switchable_output_1", + title="Switchable Output 1", + data={ + CONF_NAME: "Switchable Output 1", + CONF_SWITCHABLE_OUTPUT_NUMBER: 1, + }, +) diff --git a/tests/components/satel_integra/conftest.py b/tests/components/satel_integra/conftest.py index e91a79b96b50..a468ecd18d8b 100644 --- a/tests/components/satel_integra/conftest.py +++ b/tests/components/satel_integra/conftest.py @@ -1,12 +1,21 @@ """Satel Integra tests configuration.""" from collections.abc import Generator +from copy import deepcopy from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.satel_integra.const import DEFAULT_PORT, DOMAIN -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.components.satel_integra.const import DOMAIN + +from . import ( + MOCK_CONFIG_DATA, + MOCK_CONFIG_OPTIONS, + MOCK_OUTPUT_SUBENTRY, + MOCK_PARTITION_SUBENTRY, + MOCK_SWITCHABLE_OUTPUT_SUBENTRY, + MOCK_ZONE_SUBENTRY, +) from tests.common import MockConfigEntry @@ -28,22 +37,42 @@ def mock_satel() -> Generator[AsyncMock]: patch( "homeassistant.components.satel_integra.AsyncSatel", autospec=True, - ) as mock_client, + ) as client, patch( - "homeassistant.components.satel_integra.config_flow.AsyncSatel", - new=mock_client, + "homeassistant.components.satel_integra.config_flow.AsyncSatel", new=client ), ): - client = mock_client.return_value + client.return_value.partition_states = {} + client.return_value.violated_outputs = [] + client.return_value.violated_zones = [] + client.return_value.connect.return_value = True yield client -@pytest.fixture(name="config_entry") +@pytest.fixture def mock_config_entry() -> MockConfigEntry: """Mock satel configuration entry.""" return MockConfigEntry( domain=DOMAIN, title="192.168.0.2", - data={CONF_HOST: "192.168.0.2", CONF_PORT: DEFAULT_PORT}, + data=MOCK_CONFIG_DATA, + options=MOCK_CONFIG_OPTIONS, + entry_id="SATEL_INTEGRA_CONFIG_ENTRY_1", ) + + +@pytest.fixture +def mock_config_entry_with_subentries( + mock_config_entry: MockConfigEntry, +) -> MockConfigEntry: + """Mock satel configuration entry.""" + mock_config_entry.subentries = deepcopy( + { + MOCK_PARTITION_SUBENTRY.subentry_id: MOCK_PARTITION_SUBENTRY, + MOCK_ZONE_SUBENTRY.subentry_id: MOCK_ZONE_SUBENTRY, + MOCK_OUTPUT_SUBENTRY.subentry_id: MOCK_OUTPUT_SUBENTRY, + MOCK_SWITCHABLE_OUTPUT_SUBENTRY.subentry_id: MOCK_SWITCHABLE_OUTPUT_SUBENTRY, + } + ) + return mock_config_entry diff --git a/tests/components/satel_integra/snapshots/test_diagnostics.ambr b/tests/components/satel_integra/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..b6c99772c800 --- /dev/null +++ b/tests/components/satel_integra/snapshots/test_diagnostics.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry_data': dict({ + 'host': '192.168.0.2', + 'port': 7094, + }), + 'config_entry_options': dict({ + 'code': '**REDACTED**', + }), + 'subentries': dict({ + 'ID_OUTPUT': dict({ + 'data': dict({ + 'name': 'Output 1', + 'output_number': 1, + 'type': 'safety', + }), + 'subentry_id': 'ID_OUTPUT', + 'subentry_type': 'output', + 'title': 'Output 1', + 'unique_id': 'output_1', + }), + 'ID_PARTITION': dict({ + 'data': dict({ + 'arm_home_mode': 1, + 'name': 'Home', + 'partition_number': 1, + }), + 'subentry_id': 'ID_PARTITION', + 'subentry_type': 'partition', + 'title': 'Home', + 'unique_id': 'partition_1', + }), + 'ID_SWITCHABLE_OUTPUT': dict({ + 'data': dict({ + 'name': 'Switchable Output 1', + 'switchable_output_number': 1, + }), + 'subentry_id': 'ID_SWITCHABLE_OUTPUT', + 'subentry_type': 'switchable_output', + 'title': 'Switchable Output 1', + 'unique_id': 'switchable_output_1', + }), + 'ID_ZONE': dict({ + 'data': dict({ + 'name': 'Zone 1', + 'type': 'motion', + 'zone_number': 1, + }), + 'subentry_id': 'ID_ZONE', + 'subentry_type': 'zone', + 'title': 'Zone 1', + 'unique_id': 'zone_1', + }), + }), + }) +# --- diff --git a/tests/components/satel_integra/test_config_flow.py b/tests/components/satel_integra/test_config_flow.py index db493a3dade4..84b4aef20094 100644 --- a/tests/components/satel_integra/test_config_flow.py +++ b/tests/components/satel_integra/test_config_flow.py @@ -19,42 +19,40 @@ from homeassistant.components.satel_integra.const import ( CONF_ZONES, DEFAULT_PORT, DOMAIN, - SUBENTRY_TYPE_OUTPUT, - SUBENTRY_TYPE_PARTITION, - SUBENTRY_TYPE_SWITCHABLE_OUTPUT, - SUBENTRY_TYPE_ZONE, ) from homeassistant.config_entries import ( SOURCE_IMPORT, SOURCE_RECONFIGURE, SOURCE_USER, ConfigSubentry, - ConfigSubentryData, ) from homeassistant.const import CONF_CODE, CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from . import ( + MOCK_CONFIG_DATA, + MOCK_CONFIG_OPTIONS, + MOCK_OUTPUT_SUBENTRY, + MOCK_PARTITION_SUBENTRY, + MOCK_SWITCHABLE_OUTPUT_SUBENTRY, + MOCK_ZONE_SUBENTRY, +) -CONST_HOST = "192.168.0.2" -CONST_PORT = 7095 -CONST_CODE = "1234" +from tests.common import MockConfigEntry @pytest.mark.parametrize( ("user_input", "entry_data", "entry_options"), [ ( - {CONF_HOST: CONST_HOST, CONF_PORT: CONST_PORT, CONF_CODE: CONST_CODE}, - {CONF_HOST: CONST_HOST, CONF_PORT: CONST_PORT}, - {CONF_CODE: CONST_CODE}, + {**MOCK_CONFIG_DATA, **MOCK_CONFIG_OPTIONS}, + MOCK_CONFIG_DATA, + MOCK_CONFIG_OPTIONS, ), ( - { - CONF_HOST: CONST_HOST, - }, - {CONF_HOST: CONST_HOST, CONF_PORT: DEFAULT_PORT}, + {CONF_HOST: MOCK_CONFIG_DATA[CONF_HOST]}, + {CONF_HOST: MOCK_CONFIG_DATA[CONF_HOST], CONF_PORT: DEFAULT_PORT}, {CONF_CODE: None}, ), ], @@ -81,7 +79,7 @@ async def test_setup_flow( user_input, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == CONST_HOST + assert result["title"] == MOCK_CONFIG_DATA[CONF_HOST] assert result["data"] == entry_data assert result["options"] == entry_options @@ -92,13 +90,13 @@ async def test_setup_connection_failed( hass: HomeAssistant, mock_satel: AsyncMock, mock_setup_entry: AsyncMock ) -> None: """Test the setup flow when connection fails.""" - user_input = {CONF_HOST: CONST_HOST, CONF_PORT: CONST_PORT, CONF_CODE: CONST_CODE} + user_input = MOCK_CONFIG_DATA result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - mock_satel.connect.return_value = False + mock_satel.return_value.connect.return_value = False result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -108,7 +106,7 @@ async def test_setup_connection_failed( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} - mock_satel.connect.return_value = True + mock_satel.return_value.connect.return_value = True result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -124,9 +122,9 @@ async def test_setup_connection_failed( [ ( { - CONF_HOST: CONST_HOST, - CONF_PORT: CONST_PORT, - CONF_CODE: CONST_CODE, + CONF_HOST: MOCK_CONFIG_DATA[CONF_HOST], + CONF_PORT: MOCK_CONFIG_DATA[CONF_PORT], + CONF_CODE: MOCK_CONFIG_OPTIONS[CONF_CODE], CONF_DEVICE_PARTITIONS: { "1": {CONF_NAME: "Partition Import 1", CONF_ARM_HOME_MODE: 1} }, @@ -143,8 +141,8 @@ async def test_setup_connection_failed( "2": {CONF_NAME: "Switchable output Import 2"}, }, }, - {CONF_HOST: CONST_HOST, CONF_PORT: CONST_PORT}, - {CONF_CODE: CONST_CODE}, + MOCK_CONFIG_DATA, + MOCK_CONFIG_OPTIONS, ) ], ) @@ -162,7 +160,7 @@ async def test_import_flow( DOMAIN, context={"source": SOURCE_IMPORT}, data=import_input ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == CONST_HOST + assert result["title"] == MOCK_CONFIG_DATA[CONF_HOST] assert result["data"] == entry_data assert result["options"] == entry_options @@ -176,12 +174,12 @@ async def test_import_flow_connection_failure( ) -> None: """Test the import flow.""" - mock_satel.connect.return_value = False + mock_satel.return_value.connect.return_value = False result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, - data={CONF_HOST: CONST_HOST, CONF_PORT: CONST_PORT, CONF_CODE: CONST_CODE}, + data=MOCK_CONFIG_DATA, ) assert result["type"] is FlowResultType.ABORT @@ -191,10 +189,7 @@ async def test_import_flow_connection_failure( @pytest.mark.parametrize( ("user_input", "entry_options"), [ - ( - {CONF_CODE: CONST_CODE}, - {CONF_CODE: CONST_CODE}, - ), + (MOCK_CONFIG_OPTIONS, MOCK_CONFIG_OPTIONS), ({}, {CONF_CODE: None}), ], ) @@ -226,92 +221,29 @@ async def test_options_flow( @pytest.mark.parametrize( - ("subentry_type", "user_input", "subentry"), + ("user_input", "subentry"), [ - ( - SUBENTRY_TYPE_PARTITION, - {CONF_NAME: "Home", CONF_PARTITION_NUMBER: 1, CONF_ARM_HOME_MODE: 1}, - { - "data": { - CONF_NAME: "Home", - CONF_ARM_HOME_MODE: 1, - CONF_PARTITION_NUMBER: 1, - }, - "subentry_type": SUBENTRY_TYPE_PARTITION, - "title": "Home", - "unique_id": "partition_1", - }, - ), - ( - SUBENTRY_TYPE_ZONE, - { - CONF_NAME: "Backdoor", - CONF_ZONE_TYPE: BinarySensorDeviceClass.DOOR, - CONF_ZONE_NUMBER: 2, - }, - { - "data": { - CONF_NAME: "Backdoor", - CONF_ZONE_TYPE: BinarySensorDeviceClass.DOOR, - CONF_ZONE_NUMBER: 2, - }, - "subentry_type": SUBENTRY_TYPE_ZONE, - "title": "Backdoor", - "unique_id": "zone_2", - }, - ), - ( - SUBENTRY_TYPE_OUTPUT, - { - CONF_NAME: "Power outage", - CONF_ZONE_TYPE: BinarySensorDeviceClass.SAFETY, - CONF_OUTPUT_NUMBER: 1, - }, - { - "data": { - CONF_NAME: "Power outage", - CONF_ZONE_TYPE: BinarySensorDeviceClass.SAFETY, - CONF_OUTPUT_NUMBER: 1, - }, - "subentry_type": SUBENTRY_TYPE_OUTPUT, - "title": "Power outage", - "unique_id": "output_1", - }, - ), - ( - SUBENTRY_TYPE_SWITCHABLE_OUTPUT, - { - CONF_NAME: "Gate", - CONF_SWITCHABLE_OUTPUT_NUMBER: 3, - }, - { - "data": { - CONF_NAME: "Gate", - CONF_SWITCHABLE_OUTPUT_NUMBER: 3, - }, - "subentry_type": SUBENTRY_TYPE_SWITCHABLE_OUTPUT, - "title": "Gate", - "unique_id": "switchable_output_3", - }, - ), + (MOCK_PARTITION_SUBENTRY.data, MOCK_PARTITION_SUBENTRY), + (MOCK_ZONE_SUBENTRY.data, MOCK_ZONE_SUBENTRY), + (MOCK_OUTPUT_SUBENTRY.data, MOCK_OUTPUT_SUBENTRY), + (MOCK_SWITCHABLE_OUTPUT_SUBENTRY.data, MOCK_SWITCHABLE_OUTPUT_SUBENTRY), ], ) async def test_subentry_creation( hass: HomeAssistant, mock_satel: AsyncMock, - config_entry: MockConfigEntry, - subentry_type: str, + mock_config_entry: MockConfigEntry, user_input: dict[str, Any], - subentry: dict[str, Any], + subentry: ConfigSubentry, ) -> None: """Test partitions options flow.""" - config_entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() result = await hass.config_entries.subentries.async_init( - (config_entry.entry_id, subentry_type), + (mock_config_entry.entry_id, subentry.subentry_type), context={"source": SOURCE_USER}, ) @@ -323,118 +255,44 @@ async def test_subentry_creation( user_input, ) - assert len(config_entry.subentries) == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_config_entry.subentries) == 1 - subentry_id = list(config_entry.subentries)[0] + subentry_id = list(mock_config_entry.subentries)[0] - subentry["subentry_id"] = subentry_id - assert config_entry.subentries == {subentry_id: ConfigSubentry(**subentry)} + subentry_result = { + **subentry.as_dict(), + "subentry_id": subentry_id, + } + assert mock_config_entry.subentries.get(subentry_id) == ConfigSubentry( + **subentry_result + ) @pytest.mark.parametrize( ( "user_input", - "default_subentry_info", "subentry", - "updated_subentry", ), [ ( {CONF_NAME: "New Home", CONF_ARM_HOME_MODE: 3}, - { - "subentry_id": "ABCD", - "subentry_type": SUBENTRY_TYPE_PARTITION, - "unique_id": "partition_1", - }, - ConfigSubentryData( - data={ - CONF_NAME: "Home", - CONF_ARM_HOME_MODE: 1, - CONF_PARTITION_NUMBER: 1, - }, - title="Home", - ), - ConfigSubentryData( - data={ - CONF_NAME: "New Home", - CONF_ARM_HOME_MODE: 3, - CONF_PARTITION_NUMBER: 1, - }, - title="New Home", - ), + MOCK_PARTITION_SUBENTRY, ), ( {CONF_NAME: "Backdoor", CONF_ZONE_TYPE: BinarySensorDeviceClass.DOOR}, - { - "subentry_id": "ABCD", - "subentry_type": SUBENTRY_TYPE_ZONE, - "unique_id": "zone_1", - }, - ConfigSubentryData( - data={ - CONF_NAME: "Zone 1", - CONF_ZONE_TYPE: BinarySensorDeviceClass.MOTION, - CONF_ZONE_NUMBER: 1, - }, - title="Zone 1", - ), - ConfigSubentryData( - data={ - CONF_NAME: "Backdoor", - CONF_ZONE_TYPE: BinarySensorDeviceClass.DOOR, - CONF_ZONE_NUMBER: 1, - }, - title="Backdoor", - ), + MOCK_ZONE_SUBENTRY, ), ( { CONF_NAME: "Alarm Triggered", CONF_ZONE_TYPE: BinarySensorDeviceClass.PROBLEM, }, - { - "subentry_id": "ABCD", - "subentry_type": SUBENTRY_TYPE_OUTPUT, - "unique_id": "output_1", - }, - ConfigSubentryData( - data={ - CONF_NAME: "Output 1", - CONF_ZONE_TYPE: BinarySensorDeviceClass.SAFETY, - CONF_OUTPUT_NUMBER: 1, - }, - title="Output 1", - ), - ConfigSubentryData( - data={ - CONF_NAME: "Alarm Triggered", - CONF_ZONE_TYPE: BinarySensorDeviceClass.PROBLEM, - CONF_OUTPUT_NUMBER: 1, - }, - title="Alarm Triggered", - ), + MOCK_OUTPUT_SUBENTRY, ), ( {CONF_NAME: "Gate Lock"}, - { - "subentry_id": "ABCD", - "subentry_type": SUBENTRY_TYPE_SWITCHABLE_OUTPUT, - "unique_id": "switchable_output_1", - }, - ConfigSubentryData( - data={ - CONF_NAME: "Switchable Output 1", - CONF_SWITCHABLE_OUTPUT_NUMBER: 1, - }, - title="Switchable Output 1", - ), - ConfigSubentryData( - data={ - CONF_NAME: "Gate Lock", - CONF_SWITCHABLE_OUTPUT_NUMBER: 1, - }, - title="Gate Lock", - ), + MOCK_SWITCHABLE_OUTPUT_SUBENTRY, ), ], ) @@ -442,29 +300,27 @@ async def test_subentry_reconfigure( hass: HomeAssistant, mock_satel: AsyncMock, mock_setup_entry: AsyncMock, - config_entry: MockConfigEntry, + mock_config_entry_with_subentries: MockConfigEntry, user_input: dict[str, Any], - default_subentry_info: dict[str, Any], - subentry: ConfigSubentryData, - updated_subentry: ConfigSubentryData, + subentry: ConfigSubentry, ) -> None: """Test subentry reconfiguration.""" - config_entry.add_to_hass(hass) - config_entry.subentries = { - default_subentry_info["subentry_id"]: ConfigSubentry( - **default_subentry_info, **subentry - ) - } + mock_config_entry_with_subentries.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) + assert await hass.config_entries.async_setup( + mock_config_entry_with_subentries.entry_id + ) await hass.async_block_till_done() result = await hass.config_entries.subentries.async_init( - (config_entry.entry_id, default_subentry_info["subentry_type"]), + ( + mock_config_entry_with_subentries.entry_id, + subentry.subentry_type, + ), context={ "source": SOURCE_RECONFIGURE, - "subentry_id": default_subentry_info["subentry_id"], + "subentry_id": subentry.subentry_id, }, ) @@ -478,91 +334,48 @@ async def test_subentry_reconfigure( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" - assert len(config_entry.subentries) == 1 + assert len(mock_config_entry_with_subentries.subentries) == 4 - assert config_entry.subentries == { - default_subentry_info["subentry_id"]: ConfigSubentry( - **default_subentry_info, **updated_subentry - ) + subentry_result = { + **subentry.as_dict(), + "data": {**subentry.data, **user_input}, + "title": user_input.get(CONF_NAME), } + assert mock_config_entry_with_subentries.subentries.get( + subentry.subentry_id + ) == ConfigSubentry(**subentry_result) + @pytest.mark.parametrize( - ("subentry", "user_input", "error_field"), + ("subentry", "error_field"), [ - ( - { - "subentry_type": SUBENTRY_TYPE_PARTITION, - "unique_id": "partition_1", - "title": "Home", - }, - { - CONF_NAME: "Home", - CONF_ARM_HOME_MODE: 1, - CONF_PARTITION_NUMBER: 1, - }, - CONF_PARTITION_NUMBER, - ), - ( - { - "subentry_type": SUBENTRY_TYPE_ZONE, - "unique_id": "zone_1", - "title": "Zone 1", - }, - { - CONF_NAME: "Zone 1", - CONF_ZONE_TYPE: BinarySensorDeviceClass.MOTION, - CONF_ZONE_NUMBER: 1, - }, - CONF_ZONE_NUMBER, - ), - ( - { - "subentry_type": SUBENTRY_TYPE_OUTPUT, - "unique_id": "output_1", - "title": "Output 1", - }, - { - CONF_NAME: "Output 1", - CONF_ZONE_TYPE: BinarySensorDeviceClass.SAFETY, - CONF_OUTPUT_NUMBER: 1, - }, - CONF_OUTPUT_NUMBER, - ), - ( - { - "subentry_type": SUBENTRY_TYPE_SWITCHABLE_OUTPUT, - "unique_id": "switchable_output_1", - "title": "Switchable Output 1", - }, - { - CONF_NAME: "Switchable Output 1", - CONF_SWITCHABLE_OUTPUT_NUMBER: 1, - }, - CONF_SWITCHABLE_OUTPUT_NUMBER, - ), + (MOCK_PARTITION_SUBENTRY, CONF_PARTITION_NUMBER), + (MOCK_ZONE_SUBENTRY, CONF_ZONE_NUMBER), + (MOCK_OUTPUT_SUBENTRY, CONF_OUTPUT_NUMBER), + (MOCK_SWITCHABLE_OUTPUT_SUBENTRY, CONF_SWITCHABLE_OUTPUT_NUMBER), ], ) async def test_cannot_create_same_subentry( hass: HomeAssistant, mock_satel: AsyncMock, mock_setup_entry: AsyncMock, - config_entry: MockConfigEntry, - subentry: dict[str, any], - user_input: dict[str, any], + mock_config_entry_with_subentries: MockConfigEntry, + subentry: dict[str, Any], error_field: str, ) -> None: """Test subentry reconfiguration.""" - config_entry.add_to_hass(hass) - config_entry.subentries = { - "ABCD": ConfigSubentry(**subentry, **ConfigSubentryData({"data": user_input})) - } + mock_config_entry_with_subentries.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) + assert await hass.config_entries.async_setup( + mock_config_entry_with_subentries.entry_id + ) await hass.async_block_till_done() + mock_setup_entry.reset_mock() + result = await hass.config_entries.subentries.async_init( - (config_entry.entry_id, subentry["subentry_type"]), + (mock_config_entry_with_subentries.entry_id, subentry.subentry_type), context={"source": SOURCE_USER}, ) @@ -570,20 +383,21 @@ async def test_cannot_create_same_subentry( assert result["step_id"] == "user" result = await hass.config_entries.subentries.async_configure( - result["flow_id"], - user_input, + result["flow_id"], {**subentry.data} ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {error_field: "already_configured"} - assert len(config_entry.subentries) == 1 + assert len(mock_config_entry_with_subentries.subentries) == 4 + + assert len(mock_setup_entry.mock_calls) == 0 async def test_one_config_allowed( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test that only one Satel Integra configuration is allowed.""" - config_entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} diff --git a/tests/components/satel_integra/test_diagnostics.py b/tests/components/satel_integra/test_diagnostics.py new file mode 100644 index 000000000000..93afd530e65d --- /dev/null +++ b/tests/components/satel_integra/test_diagnostics.py @@ -0,0 +1,31 @@ +"""Tests for satel integra diagnostics.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +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, + snapshot: SnapshotAssertion, + hass_client: ClientSessionGenerator, + mock_config_entry_with_subentries: MockConfigEntry, + mock_satel: AsyncMock, +) -> None: + """Test diagnostics for config entry.""" + mock_config_entry_with_subentries.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry_with_subentries.entry_id) + await hass.async_block_till_done() + + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry_with_subentries + ) + assert diagnostics == snapshot(exclude=props("created_at", "modified_at", "id")) From 2796d6110ad129217e445d0ea1e5f8ed31eb3ead Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 22 Sep 2025 05:46:24 -0400 Subject: [PATCH 007/189] Split up media source integration (#152721) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/google_photos/media_source.py | 3 +- .../components/immich/media_source.py | 3 +- .../components/media_source/__init__.py | 170 +----------- .../components/media_source/helper.py | 103 ++++++++ homeassistant/components/media_source/http.py | 79 ++++++ .../music_assistant/media_browser.py | 2 +- homeassistant/components/roku/browse_media.py | 2 +- .../components/sonos/media_browser.py | 2 +- .../components/squeezebox/browse_media.py | 2 +- .../components/synology_dsm/media_source.py | 3 +- .../google_photos/test_media_source.py | 2 +- .../image_upload/test_media_source.py | 6 +- tests/components/immich/test_media_source.py | 9 +- tests/components/media_source/test_helper.py | 129 ++++++++++ tests/components/media_source/test_http.py | 127 +++++++++ tests/components/media_source/test_init.py | 242 ------------------ .../media_source/test_local_source.py | 9 +- tests/components/netatmo/test_media_source.py | 2 +- .../synology_dsm/test_media_source.py | 9 +- 19 files changed, 468 insertions(+), 436 deletions(-) create mode 100644 homeassistant/components/media_source/helper.py create mode 100644 homeassistant/components/media_source/http.py create mode 100644 tests/components/media_source/test_helper.py create mode 100644 tests/components/media_source/test_http.py diff --git a/homeassistant/components/google_photos/media_source.py b/homeassistant/components/google_photos/media_source.py index c0a87e46fbcb..ef6e2ef3e039 100644 --- a/homeassistant/components/google_photos/media_source.py +++ b/homeassistant/components/google_photos/media_source.py @@ -10,9 +10,8 @@ from typing import Self, cast from google_photos_library_api.exceptions import GooglePhotosApiError from google_photos_library_api.model import Album, MediaItem -from homeassistant.components.media_player import MediaClass, MediaType +from homeassistant.components.media_player import BrowseError, MediaClass, MediaType from homeassistant.components.media_source import ( - BrowseError, BrowseMediaSource, MediaSource, MediaSourceItem, diff --git a/homeassistant/components/immich/media_source.py b/homeassistant/components/immich/media_source.py index 008a807c0d24..8e824b100bcd 100644 --- a/homeassistant/components/immich/media_source.py +++ b/homeassistant/components/immich/media_source.py @@ -9,9 +9,8 @@ from aioimmich.assets.models import ImmichAsset from aioimmich.exceptions import ImmichError from homeassistant.components.http import HomeAssistantView -from homeassistant.components.media_player import MediaClass +from homeassistant.components.media_player import BrowseError, MediaClass from homeassistant.components.media_source import ( - BrowseError, BrowseMediaSource, MediaSource, MediaSourceItem, diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index 67507769720a..e15a7cb47e34 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -2,30 +2,17 @@ from __future__ import annotations -from collections.abc import Callable -from typing import Any, Protocol +from typing import Protocol -import voluptuous as vol - -from homeassistant.components import frontend, websocket_api -from homeassistant.components.media_player import ( - ATTR_MEDIA_CONTENT_ID, - CONTENT_AUTH_EXPIRY_TIME, - BrowseError, - BrowseMedia, - async_process_play_media_url, -) -from homeassistant.components.websocket_api import ActiveConnection -from homeassistant.core import HomeAssistant, callback +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.frame import report_usage from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, ) -from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType -from homeassistant.loader import bind_hass +from homeassistant.helpers.typing import ConfigType -from . import local_source +from . import http, local_source from .const import ( DOMAIN, MEDIA_CLASS_MAP, @@ -34,7 +21,8 @@ from .const import ( URI_SCHEME, URI_SCHEME_REGEX, ) -from .error import MediaSourceError, UnknownMediaSource, Unresolvable +from .error import MediaSourceError, Unresolvable +from .helper import async_browse_media, async_resolve_media from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia __all__ = [ @@ -80,11 +68,7 @@ def generate_media_source_id(domain: str, identifier: str) -> str: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the media_source component.""" hass.data[MEDIA_SOURCE_DATA] = {} - websocket_api.async_register_command(hass, websocket_browse_media) - websocket_api.async_register_command(hass, websocket_resolve_media) - frontend.async_register_built_in_panel( - hass, "media-browser", "media_browser", "hass:play-box-multiple" - ) + http.async_setup(hass) # Local sources support await _process_media_source_platform(hass, DOMAIN, local_source) @@ -107,141 +91,3 @@ async def _process_media_source_platform( hass.data[MEDIA_SOURCE_DATA][domain] = source if isinstance(source, local_source.LocalSource): hass.http.register_view(local_source.LocalMediaView(hass, source)) - - -@callback -def _get_media_item( - hass: HomeAssistant, media_content_id: str | None, target_media_player: str | None -) -> MediaSourceItem: - """Return media item.""" - if media_content_id: - item = MediaSourceItem.from_uri(hass, media_content_id, target_media_player) - else: - # We default to our own domain if its only one registered - domain = None if len(hass.data[MEDIA_SOURCE_DATA]) > 1 else DOMAIN - return MediaSourceItem(hass, domain, "", target_media_player) - - if item.domain is not None and item.domain not in hass.data[MEDIA_SOURCE_DATA]: - raise UnknownMediaSource( - translation_domain=DOMAIN, - translation_key="unknown_media_source", - translation_placeholders={"domain": item.domain}, - ) - - return item - - -@bind_hass -async def async_browse_media( - hass: HomeAssistant, - media_content_id: str | None, - *, - content_filter: Callable[[BrowseMedia], bool] | None = None, -) -> BrowseMediaSource: - """Return media player browse media results.""" - if DOMAIN not in hass.data: - raise BrowseError("Media Source not loaded") - - try: - item = await _get_media_item(hass, media_content_id, None).async_browse() - except ValueError as err: - raise BrowseError( - translation_domain=DOMAIN, - translation_key="browse_media_failed", - translation_placeholders={ - "media_content_id": str(media_content_id), - "error": str(err), - }, - ) from err - - if content_filter is None or item.children is None: - return item - - old_count = len(item.children) - item.children = [ - child for child in item.children if child.can_expand or content_filter(child) - ] - item.not_shown += old_count - len(item.children) - return item - - -@bind_hass -async def async_resolve_media( - hass: HomeAssistant, - media_content_id: str, - target_media_player: str | None | UndefinedType = UNDEFINED, -) -> PlayMedia: - """Get info to play media.""" - if DOMAIN not in hass.data: - raise Unresolvable("Media Source not loaded") - - if target_media_player is UNDEFINED: - report_usage( - "calls media_source.async_resolve_media without passing an entity_id", - exclude_integrations={DOMAIN}, - ) - target_media_player = None - - try: - item = _get_media_item(hass, media_content_id, target_media_player) - except ValueError as err: - raise Unresolvable( - translation_domain=DOMAIN, - translation_key="resolve_media_failed", - translation_placeholders={ - "media_content_id": str(media_content_id), - "error": str(err), - }, - ) from err - - return await item.async_resolve() - - -@websocket_api.websocket_command( - { - vol.Required("type"): "media_source/browse_media", - vol.Optional(ATTR_MEDIA_CONTENT_ID, default=""): str, - } -) -@websocket_api.async_response -async def websocket_browse_media( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Browse available media.""" - try: - media = await async_browse_media(hass, msg.get("media_content_id", "")) - connection.send_result( - msg["id"], - media.as_dict(), - ) - except BrowseError as err: - connection.send_error(msg["id"], "browse_media_failed", str(err)) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "media_source/resolve_media", - vol.Required(ATTR_MEDIA_CONTENT_ID): str, - vol.Optional("expires", default=CONTENT_AUTH_EXPIRY_TIME): int, - } -) -@websocket_api.async_response -async def websocket_resolve_media( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Resolve media.""" - try: - media = await async_resolve_media(hass, msg["media_content_id"], None) - except Unresolvable as err: - connection.send_error(msg["id"], "resolve_media_failed", str(err)) - return - - connection.send_result( - msg["id"], - { - "url": async_process_play_media_url( - hass, media.url, allow_relative_url=True - ), - "mime_type": media.mime_type, - }, - ) diff --git a/homeassistant/components/media_source/helper.py b/homeassistant/components/media_source/helper.py new file mode 100644 index 000000000000..940b67c33c6c --- /dev/null +++ b/homeassistant/components/media_source/helper.py @@ -0,0 +1,103 @@ +"""Helpers for media source.""" + +from __future__ import annotations + +from collections.abc import Callable + +from homeassistant.components.media_player import BrowseError, BrowseMedia +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.frame import report_usage +from homeassistant.helpers.typing import UNDEFINED, UndefinedType +from homeassistant.loader import bind_hass + +from .const import DOMAIN, MEDIA_SOURCE_DATA +from .error import UnknownMediaSource, Unresolvable +from .models import BrowseMediaSource, MediaSourceItem, PlayMedia + + +@callback +def _get_media_item( + hass: HomeAssistant, media_content_id: str | None, target_media_player: str | None +) -> MediaSourceItem: + """Return media item.""" + if media_content_id: + item = MediaSourceItem.from_uri(hass, media_content_id, target_media_player) + else: + # We default to our own domain if its only one registered + domain = None if len(hass.data[MEDIA_SOURCE_DATA]) > 1 else DOMAIN + return MediaSourceItem(hass, domain, "", target_media_player) + + if item.domain is not None and item.domain not in hass.data[MEDIA_SOURCE_DATA]: + raise UnknownMediaSource( + translation_domain=DOMAIN, + translation_key="unknown_media_source", + translation_placeholders={"domain": item.domain}, + ) + + return item + + +@bind_hass +async def async_browse_media( + hass: HomeAssistant, + media_content_id: str | None, + *, + content_filter: Callable[[BrowseMedia], bool] | None = None, +) -> BrowseMediaSource: + """Return media player browse media results.""" + if DOMAIN not in hass.data: + raise BrowseError("Media Source not loaded") + + try: + item = await _get_media_item(hass, media_content_id, None).async_browse() + except ValueError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="browse_media_failed", + translation_placeholders={ + "media_content_id": str(media_content_id), + "error": str(err), + }, + ) from err + + if content_filter is None or item.children is None: + return item + + old_count = len(item.children) + item.children = [ + child for child in item.children if child.can_expand or content_filter(child) + ] + item.not_shown += old_count - len(item.children) + return item + + +@bind_hass +async def async_resolve_media( + hass: HomeAssistant, + media_content_id: str, + target_media_player: str | None | UndefinedType = UNDEFINED, +) -> PlayMedia: + """Get info to play media.""" + if DOMAIN not in hass.data: + raise Unresolvable("Media Source not loaded") + + if target_media_player is UNDEFINED: + report_usage( + "calls media_source.async_resolve_media without passing an entity_id", + exclude_integrations={DOMAIN}, + ) + target_media_player = None + + try: + item = _get_media_item(hass, media_content_id, target_media_player) + except ValueError as err: + raise Unresolvable( + translation_domain=DOMAIN, + translation_key="resolve_media_failed", + translation_placeholders={ + "media_content_id": str(media_content_id), + "error": str(err), + }, + ) from err + + return await item.async_resolve() diff --git a/homeassistant/components/media_source/http.py b/homeassistant/components/media_source/http.py new file mode 100644 index 000000000000..3b9aaeea4ba4 --- /dev/null +++ b/homeassistant/components/media_source/http.py @@ -0,0 +1,79 @@ +"""HTTP views and WebSocket commands for media sources.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components import frontend, websocket_api +from homeassistant.components.media_player import ( + ATTR_MEDIA_CONTENT_ID, + CONTENT_AUTH_EXPIRY_TIME, + BrowseError, + async_process_play_media_url, +) +from homeassistant.components.websocket_api import ActiveConnection +from homeassistant.core import HomeAssistant + +from .error import Unresolvable +from .helper import async_browse_media, async_resolve_media + + +def async_setup(hass: HomeAssistant) -> None: + """Set up the HTTP views and WebSocket commands for media sources.""" + websocket_api.async_register_command(hass, websocket_browse_media) + websocket_api.async_register_command(hass, websocket_resolve_media) + frontend.async_register_built_in_panel( + hass, "media-browser", "media_browser", "hass:play-box-multiple" + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "media_source/browse_media", + vol.Optional(ATTR_MEDIA_CONTENT_ID, default=""): str, + } +) +@websocket_api.async_response +async def websocket_browse_media( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Browse available media.""" + try: + media = await async_browse_media(hass, msg.get("media_content_id", "")) + connection.send_result( + msg["id"], + media.as_dict(), + ) + except BrowseError as err: + connection.send_error(msg["id"], "browse_media_failed", str(err)) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "media_source/resolve_media", + vol.Required(ATTR_MEDIA_CONTENT_ID): str, + vol.Optional("expires", default=CONTENT_AUTH_EXPIRY_TIME): int, + } +) +@websocket_api.async_response +async def websocket_resolve_media( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Resolve media.""" + try: + media = await async_resolve_media(hass, msg["media_content_id"], None) + except Unresolvable as err: + connection.send_error(msg["id"], "resolve_media_failed", str(err)) + return + + connection.send_result( + msg["id"], + { + "url": async_process_play_media_url( + hass, media.url, allow_relative_url=True + ), + "mime_type": media.mime_type, + }, + ) diff --git a/homeassistant/components/music_assistant/media_browser.py b/homeassistant/components/music_assistant/media_browser.py index 23d6ab607e8c..fe50afe98e7b 100644 --- a/homeassistant/components/music_assistant/media_browser.py +++ b/homeassistant/components/music_assistant/media_browser.py @@ -143,7 +143,7 @@ async def build_main_listing(hass: HomeAssistant) -> BrowseMedia: children.extend(item.children) else: children.append(item) - except media_source.BrowseError: + except BrowseError: pass return BrowseMedia( diff --git a/homeassistant/components/roku/browse_media.py b/homeassistant/components/roku/browse_media.py index 09affe4369b7..5387963727d9 100644 --- a/homeassistant/components/roku/browse_media.py +++ b/homeassistant/components/roku/browse_media.py @@ -142,7 +142,7 @@ async def root_payload( children.extend(browse_item.children) else: children.append(browse_item) - except media_source.BrowseError: + except BrowseError: pass if len(children) == 1: diff --git a/homeassistant/components/sonos/media_browser.py b/homeassistant/components/sonos/media_browser.py index 255daf228297..6abe54323714 100644 --- a/homeassistant/components/sonos/media_browser.py +++ b/homeassistant/components/sonos/media_browser.py @@ -378,7 +378,7 @@ async def root_payload( children.extend(item.children) else: children.append(item) - except media_source.BrowseError: + except BrowseError: pass if len(children) == 1: diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index f71cc9b22d3f..436308a8920d 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -429,7 +429,7 @@ async def library_payload( ) ) - with contextlib.suppress(media_source.BrowseError): + with contextlib.suppress(BrowseError): browse = await media_source.async_browse_media( hass, None, content_filter=media_source_content_filter ) diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py index 7fafe1fecb31..9f9f308df5da 100644 --- a/homeassistant/components/synology_dsm/media_source.py +++ b/homeassistant/components/synology_dsm/media_source.py @@ -10,9 +10,8 @@ from synology_dsm.api.photos import SynoPhotosAlbum, SynoPhotosItem from synology_dsm.exceptions import SynologyDSMException from homeassistant.components import http -from homeassistant.components.media_player import MediaClass +from homeassistant.components.media_player import BrowseError, MediaClass from homeassistant.components.media_source import ( - BrowseError, BrowseMediaSource, MediaSource, MediaSourceItem, diff --git a/tests/components/google_photos/test_media_source.py b/tests/components/google_photos/test_media_source.py index ce059e4fce5c..9a3c3083591d 100644 --- a/tests/components/google_photos/test_media_source.py +++ b/tests/components/google_photos/test_media_source.py @@ -6,9 +6,9 @@ from google_photos_library_api.exceptions import GooglePhotosApiError import pytest from homeassistant.components.google_photos.const import DOMAIN, UPLOAD_SCOPE +from homeassistant.components.media_player import BrowseError from homeassistant.components.media_source import ( URI_SCHEME, - BrowseError, async_browse_media, async_resolve_media, ) diff --git a/tests/components/image_upload/test_media_source.py b/tests/components/image_upload/test_media_source.py index 3545abcb7992..9e76a67da8a0 100644 --- a/tests/components/image_upload/test_media_source.py +++ b/tests/components/image_upload/test_media_source.py @@ -8,6 +8,8 @@ from aiohttp import ClientSession import pytest from homeassistant.components import media_source +from homeassistant.components.media_player import BrowseError +from homeassistant.components.media_source import Unresolvable from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -61,7 +63,7 @@ async def test_browsing( assert item.children[0].thumbnail == f"/api/image/serve/{image_id}/256x256" with pytest.raises( - media_source.BrowseError, + BrowseError, match="Unknown item", ): await media_source.async_browse_media( @@ -84,7 +86,7 @@ async def test_resolving( invalid_id = "aabbccddeeff" with pytest.raises( - media_source.Unresolvable, + Unresolvable, match=f"Could not resolve media item: {invalid_id}", ): await media_source.async_resolve_media( diff --git a/tests/components/immich/test_media_source.py b/tests/components/immich/test_media_source.py index 6bd23b272ed0..5fe869bee426 100644 --- a/tests/components/immich/test_media_source.py +++ b/tests/components/immich/test_media_source.py @@ -14,13 +14,8 @@ from homeassistant.components.immich.media_source import ( ImmichMediaView, async_get_media_source, ) -from homeassistant.components.media_player import MediaClass -from homeassistant.components.media_source import ( - BrowseError, - BrowseMedia, - MediaSourceItem, - Unresolvable, -) +from homeassistant.components.media_player import BrowseError, BrowseMedia, MediaClass +from homeassistant.components.media_source import MediaSourceItem, Unresolvable from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockRequest, MockStreamReaderChunked diff --git a/tests/components/media_source/test_helper.py b/tests/components/media_source/test_helper.py new file mode 100644 index 000000000000..54f9e4a19b45 --- /dev/null +++ b/tests/components/media_source/test_helper.py @@ -0,0 +1,129 @@ +"""Test media source helpers.""" + +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.components import media_source +from homeassistant.components.media_player import BrowseError +from homeassistant.components.media_source import const, models +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +async def test_async_browse_media(hass: HomeAssistant) -> None: + """Test browse media.""" + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + + # Test non-media ignored (/media has test.mp3 and not_media.txt) + media = await media_source.async_browse_media(hass, "") + assert isinstance(media, media_source.models.BrowseMediaSource) + assert media.title == "media" + assert len(media.children) == 2 + + # Test content filter + media = await media_source.async_browse_media( + hass, + "", + content_filter=lambda item: item.media_content_type.startswith("video/"), + ) + assert isinstance(media, media_source.models.BrowseMediaSource) + assert media.title == "media" + assert len(media.children) == 1, media.children + media.children[0].title = "Epic Sax Guy 10 Hours" + assert media.not_shown == 1 + + # Test content filter adds to original not_shown + orig_browse = models.MediaSourceItem.async_browse + + async def not_shown_browse(self): + """Patch browsed item to set not_shown base value.""" + item = await orig_browse(self) + item.not_shown = 10 + return item + + with patch( + "homeassistant.components.media_source.models.MediaSourceItem.async_browse", + not_shown_browse, + ): + media = await media_source.async_browse_media( + hass, + "", + content_filter=lambda item: item.media_content_type.startswith("video/"), + ) + assert isinstance(media, media_source.models.BrowseMediaSource) + assert media.title == "media" + assert len(media.children) == 1, media.children + media.children[0].title = "Epic Sax Guy 10 Hours" + assert media.not_shown == 11 + + # Test invalid media content + with pytest.raises(BrowseError): + await media_source.async_browse_media(hass, "invalid") + + # Test base URI returns all domains + media = await media_source.async_browse_media(hass, const.URI_SCHEME) + assert isinstance(media, media_source.models.BrowseMediaSource) + assert len(media.children) == 1 + assert media.children[0].title == "My media" + + +async def test_async_resolve_media(hass: HomeAssistant) -> None: + """Test browse media.""" + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + + media = await media_source.async_resolve_media( + hass, + media_source.generate_media_source_id(media_source.DOMAIN, "local/test.mp3"), + None, + ) + assert isinstance(media, media_source.models.PlayMedia) + assert media.url == "/media/local/test.mp3" + assert media.mime_type == "audio/mpeg" + + +async def test_async_resolve_media_no_entity( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test browse media.""" + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + + with pytest.raises(RuntimeError): + await media_source.async_resolve_media( + hass, + media_source.generate_media_source_id( + media_source.DOMAIN, "local/test.mp3" + ), + ) + + +async def test_async_unresolve_media(hass: HomeAssistant) -> None: + """Test browse media.""" + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + + # Test no media content + with pytest.raises(media_source.Unresolvable): + await media_source.async_resolve_media(hass, "", None) + + # Test invalid media content + with pytest.raises(media_source.Unresolvable): + await media_source.async_resolve_media(hass, "invalid", None) + + # Test invalid media source + with pytest.raises(media_source.Unresolvable): + await media_source.async_resolve_media( + hass, "media-source://media_source2", None + ) + + +async def test_browse_resolve_without_setup() -> None: + """Test browse and resolve work without being setup.""" + with pytest.raises(BrowseError): + await media_source.async_browse_media(Mock(data={}), None) + + with pytest.raises(media_source.Unresolvable): + await media_source.async_resolve_media(Mock(data={}), None, None) diff --git a/tests/components/media_source/test_http.py b/tests/components/media_source/test_http.py new file mode 100644 index 000000000000..be69bad753f5 --- /dev/null +++ b/tests/components/media_source/test_http.py @@ -0,0 +1,127 @@ +"""Test media source HTTP.""" + +from unittest.mock import patch + +import pytest +import yarl + +from homeassistant.components import media_source +from homeassistant.components.media_player import BrowseError, MediaClass +from homeassistant.components.media_source import const +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.typing import WebSocketGenerator + + +async def test_websocket_browse_media( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test browse media websocket.""" + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + media = media_source.models.BrowseMediaSource( + domain=media_source.DOMAIN, + identifier="/media", + title="Local Media", + media_class=MediaClass.DIRECTORY, + media_content_type="listing", + can_play=False, + can_expand=True, + ) + + with patch( + "homeassistant.components.media_source.http.async_browse_media", + return_value=media, + ): + await client.send_json( + { + "id": 1, + "type": "media_source/browse_media", + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["id"] == 1 + assert media.as_dict() == msg["result"] + + with patch( + "homeassistant.components.media_source.http.async_browse_media", + side_effect=BrowseError("test"), + ): + await client.send_json( + { + "id": 2, + "type": "media_source/browse_media", + "media_content_id": "invalid", + } + ) + + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "browse_media_failed" + assert msg["error"]["message"] == "test" + + +@pytest.mark.parametrize("filename", ["test.mp3", "Epic Sax Guy 10 Hours.mp4"]) +async def test_websocket_resolve_media( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, filename +) -> None: + """Test browse media websocket.""" + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + media = media_source.models.PlayMedia( + f"/media/local/{filename}", + "audio/mpeg", + ) + + with patch( + "homeassistant.components.media_source.http.async_resolve_media", + return_value=media, + ): + await client.send_json( + { + "id": 1, + "type": "media_source/resolve_media", + "media_content_id": f"{const.URI_SCHEME}{media_source.DOMAIN}/local/{filename}", + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["id"] == 1 + assert msg["result"]["mime_type"] == media.mime_type + + # Validate url is relative and signed. + assert msg["result"]["url"][0] == "/" + parsed = yarl.URL(msg["result"]["url"]) + assert parsed.path == media.url + assert "authSig" in parsed.query + + with patch( + "homeassistant.components.media_source.http.async_resolve_media", + side_effect=media_source.Unresolvable("test"), + ): + await client.send_json( + { + "id": 2, + "type": "media_source/resolve_media", + "media_content_id": "invalid", + } + ) + + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "resolve_media_failed" + assert msg["error"]["message"] == "test" diff --git a/tests/components/media_source/test_init.py b/tests/components/media_source/test_init.py index 1849fbc09abc..376aa7a4df3c 100644 --- a/tests/components/media_source/test_init.py +++ b/tests/components/media_source/test_init.py @@ -1,17 +1,6 @@ """Test Media Source initialization.""" -from unittest.mock import Mock, patch - -import pytest -import yarl - from homeassistant.components import media_source -from homeassistant.components.media_player import BrowseError, MediaClass -from homeassistant.components.media_source import const, models -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from tests.typing import WebSocketGenerator async def test_is_media_source_id() -> None: @@ -39,234 +28,3 @@ async def test_generate_media_source_id() -> None: assert media_source.is_media_source_id( media_source.generate_media_source_id(domain, identifier) ) - - -async def test_async_browse_media(hass: HomeAssistant) -> None: - """Test browse media.""" - assert await async_setup_component(hass, media_source.DOMAIN, {}) - await hass.async_block_till_done() - - # Test non-media ignored (/media has test.mp3 and not_media.txt) - media = await media_source.async_browse_media(hass, "") - assert isinstance(media, media_source.models.BrowseMediaSource) - assert media.title == "media" - assert len(media.children) == 2 - - # Test content filter - media = await media_source.async_browse_media( - hass, - "", - content_filter=lambda item: item.media_content_type.startswith("video/"), - ) - assert isinstance(media, media_source.models.BrowseMediaSource) - assert media.title == "media" - assert len(media.children) == 1, media.children - media.children[0].title = "Epic Sax Guy 10 Hours" - assert media.not_shown == 1 - - # Test content filter adds to original not_shown - orig_browse = models.MediaSourceItem.async_browse - - async def not_shown_browse(self): - """Patch browsed item to set not_shown base value.""" - item = await orig_browse(self) - item.not_shown = 10 - return item - - with patch( - "homeassistant.components.media_source.models.MediaSourceItem.async_browse", - not_shown_browse, - ): - media = await media_source.async_browse_media( - hass, - "", - content_filter=lambda item: item.media_content_type.startswith("video/"), - ) - assert isinstance(media, media_source.models.BrowseMediaSource) - assert media.title == "media" - assert len(media.children) == 1, media.children - media.children[0].title = "Epic Sax Guy 10 Hours" - assert media.not_shown == 11 - - # Test invalid media content - with pytest.raises(BrowseError): - await media_source.async_browse_media(hass, "invalid") - - # Test base URI returns all domains - media = await media_source.async_browse_media(hass, const.URI_SCHEME) - assert isinstance(media, media_source.models.BrowseMediaSource) - assert len(media.children) == 1 - assert media.children[0].title == "My media" - - -async def test_async_resolve_media(hass: HomeAssistant) -> None: - """Test browse media.""" - assert await async_setup_component(hass, media_source.DOMAIN, {}) - await hass.async_block_till_done() - - media = await media_source.async_resolve_media( - hass, - media_source.generate_media_source_id(media_source.DOMAIN, "local/test.mp3"), - None, - ) - assert isinstance(media, media_source.models.PlayMedia) - assert media.url == "/media/local/test.mp3" - assert media.mime_type == "audio/mpeg" - - -async def test_async_resolve_media_no_entity( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test browse media.""" - assert await async_setup_component(hass, media_source.DOMAIN, {}) - await hass.async_block_till_done() - - with pytest.raises(RuntimeError): - await media_source.async_resolve_media( - hass, - media_source.generate_media_source_id( - media_source.DOMAIN, "local/test.mp3" - ), - ) - - -async def test_async_unresolve_media(hass: HomeAssistant) -> None: - """Test browse media.""" - assert await async_setup_component(hass, media_source.DOMAIN, {}) - await hass.async_block_till_done() - - # Test no media content - with pytest.raises(media_source.Unresolvable): - await media_source.async_resolve_media(hass, "", None) - - # Test invalid media content - with pytest.raises(media_source.Unresolvable): - await media_source.async_resolve_media(hass, "invalid", None) - - # Test invalid media source - with pytest.raises(media_source.Unresolvable): - await media_source.async_resolve_media( - hass, "media-source://media_source2", None - ) - - -async def test_websocket_browse_media( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator -) -> None: - """Test browse media websocket.""" - assert await async_setup_component(hass, media_source.DOMAIN, {}) - await hass.async_block_till_done() - - client = await hass_ws_client(hass) - - media = media_source.models.BrowseMediaSource( - domain=media_source.DOMAIN, - identifier="/media", - title="Local Media", - media_class=MediaClass.DIRECTORY, - media_content_type="listing", - can_play=False, - can_expand=True, - ) - - with patch( - "homeassistant.components.media_source.async_browse_media", - return_value=media, - ): - await client.send_json( - { - "id": 1, - "type": "media_source/browse_media", - } - ) - - msg = await client.receive_json() - - assert msg["success"] - assert msg["id"] == 1 - assert media.as_dict() == msg["result"] - - with patch( - "homeassistant.components.media_source.async_browse_media", - side_effect=BrowseError("test"), - ): - await client.send_json( - { - "id": 2, - "type": "media_source/browse_media", - "media_content_id": "invalid", - } - ) - - msg = await client.receive_json() - - assert not msg["success"] - assert msg["error"]["code"] == "browse_media_failed" - assert msg["error"]["message"] == "test" - - -@pytest.mark.parametrize("filename", ["test.mp3", "Epic Sax Guy 10 Hours.mp4"]) -async def test_websocket_resolve_media( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, filename -) -> None: - """Test browse media websocket.""" - assert await async_setup_component(hass, media_source.DOMAIN, {}) - await hass.async_block_till_done() - - client = await hass_ws_client(hass) - - media = media_source.models.PlayMedia( - f"/media/local/{filename}", - "audio/mpeg", - ) - - with patch( - "homeassistant.components.media_source.async_resolve_media", - return_value=media, - ): - await client.send_json( - { - "id": 1, - "type": "media_source/resolve_media", - "media_content_id": f"{const.URI_SCHEME}{media_source.DOMAIN}/local/{filename}", - } - ) - - msg = await client.receive_json() - - assert msg["success"] - assert msg["id"] == 1 - assert msg["result"]["mime_type"] == media.mime_type - - # Validate url is relative and signed. - assert msg["result"]["url"][0] == "/" - parsed = yarl.URL(msg["result"]["url"]) - assert parsed.path == media.url - assert "authSig" in parsed.query - - with patch( - "homeassistant.components.media_source.async_resolve_media", - side_effect=media_source.Unresolvable("test"), - ): - await client.send_json( - { - "id": 2, - "type": "media_source/resolve_media", - "media_content_id": "invalid", - } - ) - - msg = await client.receive_json() - - assert not msg["success"] - assert msg["error"]["code"] == "resolve_media_failed" - assert msg["error"]["message"] == "test" - - -async def test_browse_resolve_without_setup() -> None: - """Test browse and resolve work without being setup.""" - with pytest.raises(BrowseError): - await media_source.async_browse_media(Mock(data={}), None) - - with pytest.raises(media_source.Unresolvable): - await media_source.async_resolve_media(Mock(data={}), None, None) diff --git a/tests/components/media_source/test_local_source.py b/tests/components/media_source/test_local_source.py index d897c6216ae3..a4020b5b2167 100644 --- a/tests/components/media_source/test_local_source.py +++ b/tests/components/media_source/test_local_source.py @@ -10,6 +10,7 @@ from unittest.mock import patch import pytest from homeassistant.components import media_source, websocket_api +from homeassistant.components.media_player import BrowseError from homeassistant.components.media_source import const from homeassistant.core import HomeAssistant from homeassistant.core_config import async_process_ha_core_config @@ -45,28 +46,28 @@ async def test_async_browse_media(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Test path not exists - with pytest.raises(media_source.BrowseError) as excinfo: + with pytest.raises(BrowseError) as excinfo: await media_source.async_browse_media( hass, f"{const.URI_SCHEME}{const.DOMAIN}/local/test/not/exist" ) assert str(excinfo.value) == "Path does not exist." # Test browse file - with pytest.raises(media_source.BrowseError) as excinfo: + with pytest.raises(BrowseError) as excinfo: await media_source.async_browse_media( hass, f"{const.URI_SCHEME}{const.DOMAIN}/local/test.mp3" ) assert str(excinfo.value) == "Path is not a directory." # Test invalid base - with pytest.raises(media_source.BrowseError) as excinfo: + with pytest.raises(BrowseError) as excinfo: await media_source.async_browse_media( hass, f"{const.URI_SCHEME}{const.DOMAIN}/invalid/base" ) assert str(excinfo.value) == "Unknown source directory." # Test directory traversal - with pytest.raises(media_source.BrowseError) as excinfo: + with pytest.raises(BrowseError) as excinfo: await media_source.async_browse_media( hass, f"{const.URI_SCHEME}{const.DOMAIN}/local/../configuration.yaml" ) diff --git a/tests/components/netatmo/test_media_source.py b/tests/components/netatmo/test_media_source.py index 755893adb112..6279f3ff429a 100644 --- a/tests/components/netatmo/test_media_source.py +++ b/tests/components/netatmo/test_media_source.py @@ -4,10 +4,10 @@ import ast import pytest +from homeassistant.components.media_player import BrowseError from homeassistant.components.media_source import ( DOMAIN as MS_DOMAIN, URI_SCHEME, - BrowseError, PlayMedia, async_browse_media, async_resolve_media, diff --git a/tests/components/synology_dsm/test_media_source.py b/tests/components/synology_dsm/test_media_source.py index d66688575bc1..1980b8b9e69f 100644 --- a/tests/components/synology_dsm/test_media_source.py +++ b/tests/components/synology_dsm/test_media_source.py @@ -9,13 +9,8 @@ import pytest from synology_dsm.api.photos import SynoPhotosAlbum, SynoPhotosItem from synology_dsm.exceptions import SynologyDSMException -from homeassistant.components.media_player import MediaClass -from homeassistant.components.media_source import ( - BrowseError, - BrowseMedia, - MediaSourceItem, - Unresolvable, -) +from homeassistant.components.media_player import BrowseError, BrowseMedia, MediaClass +from homeassistant.components.media_source import MediaSourceItem, Unresolvable from homeassistant.components.synology_dsm.const import DOMAIN from homeassistant.components.synology_dsm.media_source import ( SynologyDsmMediaView, From 1151fa698d2bdeb655b825e6df9c5ff695b3d1e8 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Mon, 22 Sep 2025 18:47:18 +0900 Subject: [PATCH 008/189] Add energy usage sensors of ThinQ devices. (#152141) Co-authored-by: yunseon.park --- .../components/lg_thinq/coordinator.py | 4 + homeassistant/components/lg_thinq/entity.py | 7 +- homeassistant/components/lg_thinq/icons.json | 9 + homeassistant/components/lg_thinq/sensor.py | 146 ++++++++++++++- .../components/lg_thinq/strings.json | 9 + tests/components/lg_thinq/conftest.py | 18 +- .../air_conditioner/energy_last_month.json | 7 + .../air_conditioner/energy_profile.json | 6 + .../air_conditioner/energy_this_month.json | 7 + .../air_conditioner/energy_yesterday.json | 7 + .../fixtures/washer/energy_profile.json | 6 + .../lg_thinq/snapshots/test_sensor.ambr | 168 ++++++++++++++++++ tests/components/lg_thinq/test_sensor.py | 58 +++++- 13 files changed, 440 insertions(+), 12 deletions(-) create mode 100644 tests/components/lg_thinq/fixtures/air_conditioner/energy_last_month.json create mode 100644 tests/components/lg_thinq/fixtures/air_conditioner/energy_profile.json create mode 100644 tests/components/lg_thinq/fixtures/air_conditioner/energy_this_month.json create mode 100644 tests/components/lg_thinq/fixtures/air_conditioner/energy_yesterday.json create mode 100644 tests/components/lg_thinq/fixtures/washer/energy_profile.json diff --git a/homeassistant/components/lg_thinq/coordinator.py b/homeassistant/components/lg_thinq/coordinator.py index ffdde3188db6..0a51b856131f 100644 --- a/homeassistant/components/lg_thinq/coordinator.py +++ b/homeassistant/components/lg_thinq/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +from datetime import time import logging from typing import TYPE_CHECKING, Any @@ -70,6 +71,9 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): event_filter=self.async_config_update_filter, ) ) + # Time of day for fetching the device's energy usage + # (randomly assigned when device is first created in Home Assistant) + self.update_energy_at_time_of_day: time | None = None async def _handle_update_config(self, _: Event) -> None: """Handle update core config.""" diff --git a/homeassistant/components/lg_thinq/entity.py b/homeassistant/components/lg_thinq/entity.py index 61d8199f3219..3c41b3e8facc 100644 --- a/homeassistant/components/lg_thinq/entity.py +++ b/homeassistant/components/lg_thinq/entity.py @@ -34,6 +34,7 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): coordinator: DeviceDataUpdateCoordinator, entity_description: EntityDescription, property_id: str, + postfix_id: str | None = None, ) -> None: """Initialize an entity.""" super().__init__(coordinator) @@ -48,7 +49,11 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): model=f"{coordinator.api.device.model_name} ({self.coordinator.api.device.device_type})", name=coordinator.device_name, ) - self._attr_unique_id = f"{coordinator.unique_id}_{self.property_id}" + self._attr_unique_id = ( + f"{coordinator.unique_id}_{self.property_id}" + if postfix_id is None + else f"{coordinator.unique_id}_{self.property_id}_{postfix_id}" + ) if self.location is not None and self.location not in ( Location.MAIN, Location.OVEN, diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json index f7001a92b9dc..b384370be647 100644 --- a/homeassistant/components/lg_thinq/icons.json +++ b/homeassistant/components/lg_thinq/icons.json @@ -440,6 +440,15 @@ }, "cycle_count_for_location": { "default": "mdi:counter" + }, + "energy_usage_yesterday": { + "default": "mdi:chart-bar" + }, + "energy_usage_this_month": { + "default": "mdi:chart-bar" + }, + "energy_usage_last_month": { + "default": "mdi:chart-bar" } } } diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index 44dfd251dc61..2161504b902a 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -2,10 +2,13 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from datetime import datetime, time, timedelta import logging +import random -from thinqconnect import DeviceType +from thinqconnect import USAGE_DAILY, USAGE_MONTHLY, DeviceType, ThinQAPIException from thinqconnect.devices.const import Property as ThinQProperty from thinqconnect.integration import ActiveMode, ThinQPropertyEx, TimerProperty @@ -18,11 +21,13 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, PERCENTAGE, + UnitOfEnergy, UnitOfTemperature, UnitOfTime, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.event import async_track_point_in_time from homeassistant.util import dt as dt_util from . import ThinqConfigEntry @@ -553,6 +558,44 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = ), } + +@dataclass(frozen=True, kw_only=True) +class ThinQEnergySensorEntityDescription(SensorEntityDescription): + """Describes ThinQ energy sensor entity.""" + + device_class = SensorDeviceClass.ENERGY + state_class = SensorStateClass.TOTAL + native_unit_of_measurement = UnitOfEnergy.WATT_HOUR + suggested_display_precision = 0 + usage_period: str + start_date_fn: Callable[[datetime], datetime] + end_date_fn: Callable[[datetime], datetime] + update_interval: timedelta = timedelta(days=1) + + +ENERGY_USAGE_SENSORS: tuple[ThinQEnergySensorEntityDescription, ...] = ( + ThinQEnergySensorEntityDescription( + key="yesterday", + translation_key="energy_usage_yesterday", + usage_period=USAGE_DAILY, + start_date_fn=lambda today: today - timedelta(days=1), + end_date_fn=lambda today: today - timedelta(days=1), + ), + ThinQEnergySensorEntityDescription( + key="this_month", + translation_key="energy_usage_this_month", + usage_period=USAGE_MONTHLY, + start_date_fn=lambda today: today, + end_date_fn=lambda today: today, + ), + ThinQEnergySensorEntityDescription( + key="last_month", + translation_key="energy_usage_last_month", + usage_period=USAGE_MONTHLY, + start_date_fn=lambda today: today.replace(day=1) - timedelta(days=1), + end_date_fn=lambda today: today.replace(day=1) - timedelta(days=1), + ), +) _LOGGER = logging.getLogger(__name__) @@ -562,7 +605,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up an entry for sensor platform.""" - entities: list[ThinQSensorEntity] = [] + entities: list[ThinQSensorEntity | ThinQEnergySensorEntity] = [] for coordinator in entry.runtime_data.coordinators.values(): if ( descriptions := DEVICE_TYPE_SENSOR_MAP.get( @@ -584,7 +627,23 @@ async def async_setup_entry( ), ) ) - + for energy_description in ENERGY_USAGE_SENSORS: + entities.extend( + ThinQEnergySensorEntity( + coordinator=coordinator, + entity_description=energy_description, + property_id=energy_property_id, + postfix_id=energy_description.key, + ) + for energy_property_id in coordinator.api.get_active_idx( + ( + ThinQPropertyEx.ENERGY_USAGE + if coordinator.sub_id is None + else f"{ThinQPropertyEx.ENERGY_USAGE}_{coordinator.sub_id}" + ), + ActiveMode.READ_ONLY, + ) + ) if entities: async_add_entities(entities) @@ -686,3 +745,84 @@ class ThinQSensorEntity(ThinQEntity, SensorEntity): if unit == UnitOfTime.SECONDS: return (data.hour * 3600) + (data.minute * 60) + data.second return 0 + + +class ThinQEnergySensorEntity(ThinQEntity, SensorEntity): + """Represent a ThinQ energy sensor platform.""" + + entity_description: ThinQEnergySensorEntityDescription + _stop_update: Callable[[], None] | None = None + + async def async_added_to_hass(self) -> None: + """Handle added to Hass.""" + await super().async_added_to_hass() + if self.coordinator.update_energy_at_time_of_day is None: + # random time 01:00:00 ~ 02:59:00 + self.coordinator.update_energy_at_time_of_day = time( + hour=random.randint(1, 2), minute=random.randint(0, 59) + ) + _LOGGER.debug( + "[%s] Set energy update time: %s", + self.coordinator.device_name, + self.coordinator.update_energy_at_time_of_day, + ) + + await self._async_update_and_schedule() + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + if self._stop_update is not None: + self._stop_update() + return await super().async_will_remove_from_hass() + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available or self.native_value is not None + + async def async_update(self, now: datetime | None = None) -> None: + """Update the state of the sensor.""" + await self._async_update_and_schedule() + self.async_write_ha_state() + + async def _async_update_and_schedule(self) -> None: + """Update the state of the sensor.""" + local_now = datetime.now( + dt_util.get_time_zone(self.coordinator.hass.config.time_zone) + ) + next_update = local_now + self.entity_description.update_interval + if self.coordinator.update_energy_at_time_of_day is not None: + # calculate next_update time by combining tomorrow and update_energy_at_time_of_day + next_update = datetime.combine( + (next_update).date(), + self.coordinator.update_energy_at_time_of_day, + next_update.tzinfo, + ) + try: + self._attr_native_value = await self.coordinator.api.async_get_energy_usage( + energy_property=self.property_id, + period=self.entity_description.usage_period, + start_date=(self.entity_description.start_date_fn(local_now)).date(), + end_date=(self.entity_description.end_date_fn(local_now)).date(), + detail=False, + ) + except ThinQAPIException as exc: + _LOGGER.warning( + "[%s:%s] Failed to fetch energy usage data. reason=%s", + self.coordinator.device_name, + self.entity_description.key, + exc, + ) + finally: + _LOGGER.debug( + "[%s:%s] async_update_and_schedule next_update: %s, native_value: %s", + self.coordinator.device_name, + self.entity_description.key, + next_update, + self._attr_native_value, + ) + self._stop_update = async_track_point_in_time( + self.coordinator.hass, + self.async_update, + next_update, + ) diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 52b9ea4a3462..9758585c6e40 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -923,6 +923,15 @@ }, "cycle_count_for_location": { "name": "{location} cycles" + }, + "energy_usage_yesterday": { + "name": "Energy yesterday" + }, + "energy_usage_this_month": { + "name": "Energy this month" + }, + "energy_usage_last_month": { + "name": "Energy last month" } }, "select": { diff --git a/tests/components/lg_thinq/conftest.py b/tests/components/lg_thinq/conftest.py index b830b0b44e4f..c762d906568c 100644 --- a/tests/components/lg_thinq/conftest.py +++ b/tests/components/lg_thinq/conftest.py @@ -118,13 +118,21 @@ def mock_thinq_mqtt_client() -> Generator[None]: "washer", ] ) -def device_fixture( - mock_thinq_api: AsyncMock, request: pytest.FixtureRequest -) -> Generator[str]: +def device_fixture(request: pytest.FixtureRequest) -> Generator[str]: """Return every device.""" return request.param +def energy_fixture(request: pytest.FixtureRequest) -> Generator[str]: + """Return energy period.""" + return request.param + + +def energy_usage(request: pytest.FixtureRequest) -> Generator[str]: + """Return energy usage per period.""" + return request.param + + @pytest.fixture def devices(mock_thinq_api: AsyncMock, device_fixture: str) -> Generator[AsyncMock]: """Return a specific device.""" @@ -137,5 +145,7 @@ def devices(mock_thinq_api: AsyncMock, device_fixture: str) -> Generator[AsyncMo mock_thinq_api.async_get_device_status.return_value = load_json_object_fixture( f"{device_fixture}/status.json", DOMAIN ) - mock_thinq_api.async_get_device_energy_profile.return_value = MagicMock() + mock_thinq_api.async_get_device_energy_profile.return_value = ( + load_json_object_fixture(f"{device_fixture}/energy_profile.json", DOMAIN) + ) return mock_thinq_api diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/energy_last_month.json b/tests/components/lg_thinq/fixtures/air_conditioner/energy_last_month.json new file mode 100644 index 000000000000..18eea27aedbf --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner/energy_last_month.json @@ -0,0 +1,7 @@ +{ + "resultCode": "0000", + "result": { + "dataList": [{ "energyUsage": 700.0, "usedDate": "202409" }], + "property": ["energyUsage"] + } +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/energy_profile.json b/tests/components/lg_thinq/fixtures/air_conditioner/energy_profile.json new file mode 100644 index 000000000000..b27926136448 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner/energy_profile.json @@ -0,0 +1,6 @@ +{ + "resultCode": "0000", + "result": { + "property": ["energyUsage"] + } +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/energy_this_month.json b/tests/components/lg_thinq/fixtures/air_conditioner/energy_this_month.json new file mode 100644 index 000000000000..0dc2d46724be --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner/energy_this_month.json @@ -0,0 +1,7 @@ +{ + "resultCode": "0000", + "result": { + "dataList": [{ "energyUsage": 500.0, "usedDate": "202410" }], + "property": ["energyUsage"] + } +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/energy_yesterday.json b/tests/components/lg_thinq/fixtures/air_conditioner/energy_yesterday.json new file mode 100644 index 000000000000..e386c1068973 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner/energy_yesterday.json @@ -0,0 +1,7 @@ +{ + "resultCode": "0000", + "result": { + "dataList": [{ "energyUsage": 100.0, "usedDate": "20241009" }], + "property": ["energyUsage"] + } +} diff --git a/tests/components/lg_thinq/fixtures/washer/energy_profile.json b/tests/components/lg_thinq/fixtures/washer/energy_profile.json new file mode 100644 index 000000000000..b27926136448 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/washer/energy_profile.json @@ -0,0 +1,6 @@ +{ + "resultCode": "0000", + "result": { + "property": ["energyUsage"] + } +} diff --git a/tests/components/lg_thinq/snapshots/test_sensor.ambr b/tests/components/lg_thinq/snapshots/test_sensor.ambr index 1ab4ede5a5b4..4bf3609bdfcf 100644 --- a/tests/components/lg_thinq/snapshots/test_sensor.ambr +++ b/tests/components/lg_thinq/snapshots/test_sensor.ambr @@ -415,3 +415,171 @@ 'state': '2024-10-10T13:14:00+00:00', }) # --- +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_energy_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.test_air_conditioner_energy_yesterday', + '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': 'Energy yesterday', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_usage_yesterday', + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_energyUsage_yesterday', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_energy_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test air conditioner Energy yesterday', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_energy_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_energy_this_month-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_air_conditioner_energy_this_month', + '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': 'Energy this month', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_usage_this_month', + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_energyUsage_this_month', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_energy_this_month-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test air conditioner Energy this month', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_energy_this_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_energy_last_month-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_air_conditioner_energy_last_month', + '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': 'Energy last month', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_usage_last_month', + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_energyUsage_last_month', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_energy_last_month-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test air conditioner Energy last month', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_energy_last_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- \ No newline at end of file diff --git a/tests/components/lg_thinq/test_sensor.py b/tests/components/lg_thinq/test_sensor.py index 87f03de6c0de..fa986c37f484 100644 --- a/tests/components/lg_thinq/test_sensor.py +++ b/tests/components/lg_thinq/test_sensor.py @@ -1,18 +1,26 @@ """Tests for the LG Thinq sensor platform.""" -from datetime import UTC, datetime +from datetime import UTC, datetime, time, timedelta from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.components.lg_thinq.const import DOMAIN +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util.dt import utcnow from . import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_load_json_object_fixture, + snapshot_platform, +) @pytest.mark.parametrize("device_fixture", ["air_conditioner"]) @@ -22,7 +30,6 @@ async def test_sensor_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, devices: AsyncMock, - mock_thinq_api: AsyncMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: @@ -32,3 +39,46 @@ async def test_sensor_entities( await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("device_fixture", "energy_fixture", "energy_usage"), + [ + ("air_conditioner", "yesterday", 100), + ("air_conditioner", "this_month", 500), + ("air_conditioner", "last_month", 700), + ], +) +@pytest.mark.freeze_time(datetime(2024, 10, 9, 10, 0, tzinfo=UTC)) +async def test_update_energy_entity( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_thinq_api: AsyncMock, + device_fixture: str, + energy_fixture: str, + energy_usage: int, + freezer: FrozenDateTimeFactory, +) -> None: + """Test update energy entity.""" + hass.config.time_zone = "UTC" + with patch( + "homeassistant.components.lg_thinq.sensor.random.randint", return_value=1 + ): + await setup_integration(hass, mock_config_entry) + + entity_id = f"sensor.test_{device_fixture}_energy_{energy_fixture}" + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN + + mock_thinq_api.async_get_device_energy_usage.return_value = ( + await async_load_json_object_fixture( + hass, f"{device_fixture}/energy_{energy_fixture}.json", DOMAIN + ) + ) + freezer.move_to(datetime.combine(utcnow() + timedelta(days=1), time(1, 1))) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert float(state.state) == energy_usage From 868ded141fe098cbb6d1b9d83ff9e80b8b0829f9 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 22 Sep 2025 11:48:37 +0200 Subject: [PATCH 009/189] Use automatic reload options flow in threshold (#152684) --- homeassistant/components/threshold/__init__.py | 9 +-------- homeassistant/components/threshold/config_flow.py | 1 + tests/components/threshold/test_init.py | 1 + 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/threshold/__init__.py b/homeassistant/components/threshold/__init__.py index 56d51f4f1e0c..bb57170904f1 100644 --- a/homeassistant/components/threshold/__init__.py +++ b/homeassistant/components/threshold/__init__.py @@ -32,6 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, options={**entry.options, CONF_ENTITY_ID: source_entity_id}, ) + hass.config_entries.async_schedule_reload(entry.entry_id) entry.async_on_unload( async_handle_source_entity_changes( @@ -50,8 +51,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, (Platform.BINARY_SENSOR,) ) - entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) - return True @@ -89,12 +88,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return True -async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update listener, called when the config entry options are changed.""" - - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms( diff --git a/homeassistant/components/threshold/config_flow.py b/homeassistant/components/threshold/config_flow.py index 29f4a0986c14..93468e89b46a 100644 --- a/homeassistant/components/threshold/config_flow.py +++ b/homeassistant/components/threshold/config_flow.py @@ -84,6 +84,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW + options_flow_reloads = True def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" diff --git a/tests/components/threshold/test_init.py b/tests/components/threshold/test_init.py index fed35bc65020..0fc480db37ab 100644 --- a/tests/components/threshold/test_init.py +++ b/tests/components/threshold/test_init.py @@ -202,6 +202,7 @@ async def test_entry_changed(hass: HomeAssistant, platform) -> None: hass.config_entries.async_update_entry( config_entry, options={**config_entry.options, "entity_id": "sensor.changed"} ) + hass.config_entries.async_schedule_reload(config_entry.entry_id) await hass.async_block_till_done() # Check that the device association has updated From e5658f97473eb75b804555339e42198a9159888b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 22 Sep 2025 11:48:58 +0200 Subject: [PATCH 010/189] Use automatic reload options flow in statistics (#152682) --- homeassistant/components/statistics/__init__.py | 7 +------ homeassistant/components/statistics/config_flow.py | 1 + 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/statistics/__init__.py b/homeassistant/components/statistics/__init__.py index 34799e366d13..5c80fd1b9178 100644 --- a/homeassistant/components/statistics/__init__.py +++ b/homeassistant/components/statistics/__init__.py @@ -35,6 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, options={**entry.options, CONF_ENTITY_ID: source_entity_id}, ) + hass.config_entries.async_schedule_reload(entry.entry_id) async def source_entity_removed() -> None: # The source entity has been removed, we remove the config entry because @@ -56,7 +57,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 @@ -98,8 +98,3 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Statistics 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/statistics/config_flow.py b/homeassistant/components/statistics/config_flow.py index d9ff172e0a44..0375ab10777c 100644 --- a/homeassistant/components/statistics/config_flow.py +++ b/homeassistant/components/statistics/config_flow.py @@ -165,6 +165,7 @@ class StatisticsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW + options_flow_reloads = True def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" From 71cc3b7fcd2742dbc070cb999816c1d09a1c3402 Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Mon, 22 Sep 2025 17:51:39 +0800 Subject: [PATCH 011/189] Add K11+ Vacuum for switchbot integration (#152643) --- .../components/switchbot/__init__.py | 6 +++-- homeassistant/components/switchbot/const.py | 2 ++ tests/components/switchbot/__init__.py | 22 +++++++++++++++++++ tests/components/switchbot/test_vacuum.py | 2 ++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index ce0e8412b868..fa2422923bb8 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -74,11 +74,12 @@ PLATFORMS_BY_TYPE = { ], SupportedModels.HUBMINI_MATTER.value: [Platform.SENSOR], SupportedModels.CIRCULATOR_FAN.value: [Platform.FAN, Platform.SENSOR], - SupportedModels.K20_VACUUM.value: [Platform.VACUUM, Platform.SENSOR], SupportedModels.S10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR], SupportedModels.K10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR], SupportedModels.K10_PRO_VACUUM.value: [Platform.VACUUM, Platform.SENSOR], SupportedModels.K10_PRO_COMBO_VACUUM.value: [Platform.VACUUM, Platform.SENSOR], + SupportedModels.K11_PLUS_VACUUM.value: [Platform.VACUUM, Platform.SENSOR], + SupportedModels.K20_VACUUM.value: [Platform.VACUUM, Platform.SENSOR], SupportedModels.HUB3.value: [Platform.SENSOR, Platform.BINARY_SENSOR], SupportedModels.LOCK_LITE.value: [ Platform.BINARY_SENSOR, @@ -115,11 +116,12 @@ CLASS_BY_DEVICE = { SupportedModels.RELAY_SWITCH_1.value: switchbot.SwitchbotRelaySwitch, SupportedModels.ROLLER_SHADE.value: switchbot.SwitchbotRollerShade, SupportedModels.CIRCULATOR_FAN.value: switchbot.SwitchbotFan, - SupportedModels.K20_VACUUM.value: switchbot.SwitchbotVacuum, SupportedModels.S10_VACUUM.value: switchbot.SwitchbotVacuum, SupportedModels.K10_VACUUM.value: switchbot.SwitchbotVacuum, SupportedModels.K10_PRO_VACUUM.value: switchbot.SwitchbotVacuum, SupportedModels.K10_PRO_COMBO_VACUUM.value: switchbot.SwitchbotVacuum, + SupportedModels.K11_PLUS_VACUUM.value: switchbot.SwitchbotVacuum, + SupportedModels.K20_VACUUM.value: switchbot.SwitchbotVacuum, SupportedModels.LOCK_LITE.value: switchbot.SwitchbotLock, SupportedModels.LOCK_ULTRA.value: switchbot.SwitchbotLock, SupportedModels.AIR_PURIFIER.value: switchbot.SwitchbotAirPurifier, diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index c10609299d4b..247191d9c840 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -55,6 +55,7 @@ class SupportedModels(StrEnum): RGBICWW_FLOOR_LAMP = "rgbicww_floor_lamp" PLUG_MINI_EU = "plug_mini_eu" RELAY_SWITCH_2PM = "relay_switch_2pm" + K11_PLUS_VACUUM = "k11+_vacuum" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -89,6 +90,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.RGBICWW_FLOOR_LAMP: SupportedModels.RGBICWW_FLOOR_LAMP, SwitchbotModel.PLUG_MINI_EU: SupportedModels.PLUG_MINI_EU, SwitchbotModel.RELAY_SWITCH_2PM: SupportedModels.RELAY_SWITCH_2PM, + SwitchbotModel.K11_VACUUM: SupportedModels.K11_PLUS_VACUUM, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 72dc62b0b096..497b3b8a07d0 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -1105,3 +1105,25 @@ RELAY_SWITCH_2PM_SERVICE_INFO = BluetoothServiceInfoBleak( connectable=True, tx_power=-127, ) + +K11_PLUS_VACUUM_SERVICE_INFO = BluetoothServiceInfoBleak( + name="K11+ Vacuum", + manufacturer_data={2409: b"\xb0\xe9\xfe\xe4\xbf\xd8\x0b\x01\x11f\x00\x16M\x15"}, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00M\x00\x10\xfb\xa8"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="K11+ Vacuum", + manufacturer_data={2409: b"\xb0\xe9\xfe\xe4\xbf\xd8\x0b\x01\x11f\x00\x16M\x15"}, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00M\x00\x10\xfb\xa8" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "K11+ Vacuum"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_vacuum.py b/tests/components/switchbot/test_vacuum.py index 7822bda15db7..5cc579db99c2 100644 --- a/tests/components/switchbot/test_vacuum.py +++ b/tests/components/switchbot/test_vacuum.py @@ -18,6 +18,7 @@ from . import ( K10_POR_COMBO_VACUUM_SERVICE_INFO, K10_PRO_VACUUM_SERVICE_INFO, K10_VACUUM_SERVICE_INFO, + K11_PLUS_VACUUM_SERVICE_INFO, K20_VACUUM_SERVICE_INFO, S10_VACUUM_SERVICE_INFO, ) @@ -34,6 +35,7 @@ from tests.components.bluetooth import inject_bluetooth_service_info ("k10_pro_combo_vacumm", K10_POR_COMBO_VACUUM_SERVICE_INFO), ("k10_vacuum", K10_VACUUM_SERVICE_INFO), ("k10_pro_vacuum", K10_PRO_VACUUM_SERVICE_INFO), + ("k11+_vacuum", K11_PLUS_VACUUM_SERVICE_INFO), ], ) @pytest.mark.parametrize( From 82443ded34319ed553dc03bff6e1bb0a3fc91fc5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 22 Sep 2025 11:55:00 +0200 Subject: [PATCH 012/189] Use already cached data in Nord Pool if valid (#152664) --- homeassistant/components/nordpool/__init__.py | 2 +- .../components/nordpool/coordinator.py | 37 ++++++++++++------- .../components/nordpool/strings.json | 6 +++ tests/components/nordpool/test_coordinator.py | 26 ++++++++++--- 4 files changed, 51 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/nordpool/__init__.py b/homeassistant/components/nordpool/__init__.py index 77f4b263b545..dd2626aaa41c 100644 --- a/homeassistant/components/nordpool/__init__.py +++ b/homeassistant/components/nordpool/__init__.py @@ -33,7 +33,7 @@ async def async_setup_entry( await cleanup_device(hass, config_entry) coordinator = NordPoolDataUpdateCoordinator(hass, config_entry) - await coordinator.fetch_data(dt_util.utcnow()) + await coordinator.fetch_data(dt_util.utcnow(), True) if not coordinator.last_update_success: raise ConfigEntryNotReady( translation_domain=DOMAIN, diff --git a/homeassistant/components/nordpool/coordinator.py b/homeassistant/components/nordpool/coordinator.py index d2edb81b9e6f..0cda1923125d 100644 --- a/homeassistant/components/nordpool/coordinator.py +++ b/homeassistant/components/nordpool/coordinator.py @@ -13,7 +13,6 @@ from pynordpool import ( DeliveryPeriodEntry, DeliveryPeriodsData, NordPoolClient, - NordPoolEmptyResponseError, NordPoolError, NordPoolResponseError, ) @@ -22,7 +21,7 @@ from homeassistant.const import CONF_CURRENCY from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from .const import CONF_AREAS, DOMAIN, LOGGER @@ -67,14 +66,26 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): self.unsub() self.unsub = None - async def fetch_data(self, now: datetime) -> None: + async def fetch_data(self, now: datetime, initial: bool = False) -> None: """Fetch data from Nord Pool.""" self.unsub = async_track_point_in_utc_time( self.hass, self.fetch_data, self.get_next_interval(dt_util.utcnow()) ) data = await self.api_call() if data and data.entries: - self.async_set_updated_data(data) + current_day = dt_util.utcnow().strftime("%Y-%m-%d") + for entry in data.entries: + if entry.requested_date == current_day: + LOGGER.debug("Data for current day found") + self.async_set_updated_data(data) + return + if data and not data.entries and not initial: + # Empty response, use cache + LOGGER.debug("No data entries received") + return + self.async_set_update_error( + UpdateFailed(translation_domain=DOMAIN, translation_key="no_day_data") + ) async def api_call(self, retry: int = 3) -> DeliveryPeriodsData | None: """Make api call to retrieve data with retry if failure.""" @@ -96,16 +107,16 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): aiohttp.ClientError, ) as error: LOGGER.debug("Connection error: %s", error) - self.async_set_update_error(error) + if self.data is None: + self.async_set_update_error( # type: ignore[unreachable] + UpdateFailed( + translation_domain=DOMAIN, + translation_key="could_not_fetch_data", + translation_placeholders={"error": str(error)}, + ) + ) + return self.data - if data: - current_day = dt_util.utcnow().strftime("%Y-%m-%d") - for entry in data.entries: - if entry.requested_date == current_day: - LOGGER.debug("Data for current day found") - return data - - self.async_set_update_error(NordPoolEmptyResponseError("No current day data")) return data def merge_price_entries(self) -> list[DeliveryPeriodEntry]: diff --git a/homeassistant/components/nordpool/strings.json b/homeassistant/components/nordpool/strings.json index 3494996af01e..18e019ee90a7 100644 --- a/homeassistant/components/nordpool/strings.json +++ b/homeassistant/components/nordpool/strings.json @@ -157,6 +157,12 @@ }, "connection_error": { "message": "There was a connection error connecting to the API. Try again later." + }, + "no_day_data": { + "message": "Data for current day is missing" + }, + "could_not_fetch_data": { + "message": "Data could not be retrieved: {error}" } } } diff --git a/tests/components/nordpool/test_coordinator.py b/tests/components/nordpool/test_coordinator.py index c2d18c4702ab..0f6b4341b938 100644 --- a/tests/components/nordpool/test_coordinator.py +++ b/tests/components/nordpool/test_coordinator.py @@ -58,7 +58,7 @@ async def test_coordinator( await hass.async_block_till_done(wait_background_tasks=True) assert mock_data.call_count == 1 state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == STATE_UNAVAILABLE + assert state.state == "0.92505" with ( patch( @@ -72,7 +72,7 @@ async def test_coordinator( await hass.async_block_till_done(wait_background_tasks=True) assert mock_data.call_count == 1 state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == STATE_UNAVAILABLE + assert state.state == "0.94949" assert "Authentication error" in caplog.text with ( @@ -88,7 +88,7 @@ async def test_coordinator( # Empty responses does not raise assert mock_data.call_count == 3 state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == STATE_UNAVAILABLE + assert state.state == "0.94949" assert "Empty response" in caplog.text with ( @@ -103,7 +103,7 @@ async def test_coordinator( await hass.async_block_till_done(wait_background_tasks=True) assert mock_data.call_count == 1 state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == STATE_UNAVAILABLE + assert state.state == "1.25889" assert "error" in caplog.text with ( @@ -118,7 +118,7 @@ async def test_coordinator( await hass.async_block_till_done(wait_background_tasks=True) assert mock_data.call_count == 1 state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == STATE_UNAVAILABLE + assert state.state == "1.81645" assert "error" in caplog.text with ( @@ -133,7 +133,7 @@ async def test_coordinator( await hass.async_block_till_done(wait_background_tasks=True) assert mock_data.call_count == 1 state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == STATE_UNAVAILABLE + assert state.state == "2.51265" assert "Response error" in caplog.text freezer.tick(timedelta(hours=1)) @@ -141,3 +141,17 @@ async def test_coordinator( await hass.async_block_till_done() state = hass.states.get("sensor.nord_pool_se3_current_price") assert state.state == "1.81983" + + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + side_effect=NordPoolError("error"), + ) as mock_data, + ): + freezer.tick(timedelta(hours=48)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_data.call_count == 1 + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == STATE_UNAVAILABLE + assert "Data for current day is missing" in caplog.text From cb837aaae5c906a885a907c883f294d3531fa376 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Mon, 22 Sep 2025 11:55:57 +0200 Subject: [PATCH 013/189] Number snapshot testing for Plugwise (#152673) --- .../plugwise/snapshots/test_number.ambr | 709 ++++++++++++++++++ tests/components/plugwise/test_number.py | 147 ++-- 2 files changed, 786 insertions(+), 70 deletions(-) create mode 100644 tests/components/plugwise/snapshots/test_number.ambr diff --git a/tests/components/plugwise/snapshots/test_number.ambr b/tests/components/plugwise/snapshots/test_number.ambr new file mode 100644 index 000000000000..922cbb1e2bf2 --- /dev/null +++ b/tests/components/plugwise/snapshots/test_number.ambr @@ -0,0 +1,709 @@ +# serializer version: 1 +# name: test_adam_number_entities[platforms0][number.bios_cv_thermostatic_radiator_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.0, + 'min': -2.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.bios_cv_thermostatic_radiator_temperature_offset', + '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 offset', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': 'a2c3583e0a6349358998b760cea82d2a-temperature_offset', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_number_entities[platforms0][number.bios_cv_thermostatic_radiator_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Bios Cv Thermostatic Radiator Temperature offset', + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.bios_cv_thermostatic_radiator_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_number_entities[platforms0][number.cv_kraan_garage_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.0, + 'min': -2.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.cv_kraan_garage_temperature_offset', + '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 offset', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': 'e7693eb9582644e5b865dba8d4447cf1-temperature_offset', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_number_entities[platforms0][number.cv_kraan_garage_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'CV Kraan Garage Temperature offset', + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.cv_kraan_garage_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_number_entities[platforms0][number.floor_kraan_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.0, + 'min': -2.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.floor_kraan_temperature_offset', + '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 offset', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': 'b310b72a0e354bfab43089919b9a88bf-temperature_offset', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_number_entities[platforms0][number.floor_kraan_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Floor kraan Temperature offset', + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.floor_kraan_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_number_entities[platforms0][number.thermostatic_radiator_badkamer_1_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.0, + 'min': -2.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.thermostatic_radiator_badkamer_1_temperature_offset', + '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 offset', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': '680423ff840043738f42cc7f1ff97a36-temperature_offset', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_number_entities[platforms0][number.thermostatic_radiator_badkamer_1_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostatic Radiator Badkamer 1 Temperature offset', + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.thermostatic_radiator_badkamer_1_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_number_entities[platforms0][number.thermostatic_radiator_badkamer_2_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.0, + 'min': -2.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.thermostatic_radiator_badkamer_2_temperature_offset', + '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 offset', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': 'f1fee6043d3642a9b0a65297455f008e-temperature_offset', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_number_entities[platforms0][number.thermostatic_radiator_badkamer_2_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostatic Radiator Badkamer 2 Temperature offset', + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.thermostatic_radiator_badkamer_2_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_number_entities[platforms0][number.thermostatic_radiator_jessie_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.0, + 'min': -2.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.thermostatic_radiator_jessie_temperature_offset', + '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 offset', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': 'd3da73bde12a47d5a6b8f9dad971f2ec-temperature_offset', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_number_entities[platforms0][number.thermostatic_radiator_jessie_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostatic Radiator Jessie Temperature offset', + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.thermostatic_radiator_jessie_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_number_entities[platforms0][number.zone_lisa_bios_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.0, + 'min': -2.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.zone_lisa_bios_temperature_offset', + '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 offset', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': 'df4a4a8169904cdb9c03d61a21f42140-temperature_offset', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_number_entities[platforms0][number.zone_lisa_bios_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Zone Lisa Bios Temperature offset', + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.zone_lisa_bios_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_number_entities[platforms0][number.zone_lisa_wk_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.0, + 'min': -2.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.zone_lisa_wk_temperature_offset', + '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 offset', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': 'b59bcebaf94b499ea7d46e4a66fb62d8-temperature_offset', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_number_entities[platforms0][number.zone_lisa_wk_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Zone Lisa WK Temperature offset', + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.zone_lisa_wk_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_number_entities[platforms0][number.zone_thermostat_jessie_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.0, + 'min': -2.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.zone_thermostat_jessie_temperature_offset', + '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 offset', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': '6a3bf693d05e48e0b460c815a4fdd09d-temperature_offset', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_number_entities[platforms0][number.zone_thermostat_jessie_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Zone Thermostat Jessie Temperature offset', + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.zone_thermostat_jessie_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_anna_number_entities[platforms0-True-anna_heatpump_heating][number.anna_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.0, + 'min': -2.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.anna_temperature_offset', + '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 offset', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': '3cb70739631c4d17a86b8b12e8a5161b-temperature_offset', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_number_entities[platforms0-True-anna_heatpump_heating][number.anna_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Anna Temperature offset', + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.anna_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-0.5', + }) +# --- +# name: test_anna_number_entities[platforms0-True-anna_heatpump_heating][number.opentherm_domestic_hot_water_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 60.0, + 'min': 35.0, + 'mode': , + 'step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.opentherm_domestic_hot_water_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Domestic hot water setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_dhw_temperature', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-max_dhw_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_number_entities[platforms0-True-anna_heatpump_heating][number.opentherm_domestic_hot_water_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'OpenTherm Domestic hot water setpoint', + 'max': 60.0, + 'min': 35.0, + 'mode': , + 'step': 0.5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.opentherm_domestic_hot_water_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53.0', + }) +# --- +# name: test_anna_number_entities[platforms0-True-anna_heatpump_heating][number.opentherm_maximum_boiler_temperature_setpoint-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.opentherm_maximum_boiler_temperature_setpoint', + '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 boiler temperature setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'maximum_boiler_temperature', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-maximum_boiler_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_number_entities[platforms0-True-anna_heatpump_heating][number.opentherm_maximum_boiler_temperature_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'OpenTherm Maximum boiler temperature setpoint', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.opentherm_maximum_boiler_temperature_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.0', + }) +# --- diff --git a/tests/components/plugwise/test_number.py b/tests/components/plugwise/test_number.py index 4ae461d96c84..d89a0148784c 100644 --- a/tests/components/plugwise/test_number.py +++ b/tests/components/plugwise/test_number.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( ATTR_VALUE, @@ -12,81 +13,22 @@ from homeassistant.components.number import ( from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform -@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) -@pytest.mark.parametrize("cooling_present", [True], indirect=True) -async def test_anna_number_entities( - hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry -) -> None: - """Test creation of a number.""" - state = hass.states.get("number.opentherm_maximum_boiler_temperature_setpoint") - assert state - assert float(state.state) == 60.0 - - -@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) -@pytest.mark.parametrize("cooling_present", [True], indirect=True) -async def test_anna_max_boiler_temp_change( - hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry -) -> None: - """Test changing of number entities.""" - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: "number.opentherm_maximum_boiler_temperature_setpoint", - ATTR_VALUE: 65, - }, - blocking=True, - ) - - assert mock_smile_anna.set_number.call_count == 1 - mock_smile_anna.set_number.assert_called_with( - "1cbf783bb11e4a7c8a6843dee3a86927", "maximum_boiler_temperature", 65.0 - ) - - -@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) -@pytest.mark.parametrize("cooling_present", [False], indirect=True) -async def test_adam_dhw_setpoint_change( +@pytest.mark.parametrize("platforms", [(NUMBER_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_number_entities( hass: HomeAssistant, - mock_smile_adam_heat_cool: MagicMock, - init_integration: MockConfigEntry, + mock_smile_adam: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test changing of number entities.""" - state = hass.states.get("number.opentherm_domestic_hot_water_setpoint") - assert state - assert float(state.state) == 60.0 - - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: "number.opentherm_domestic_hot_water_setpoint", - ATTR_VALUE: 55, - }, - blocking=True, - ) - - assert mock_smile_adam_heat_cool.set_number.call_count == 1 - mock_smile_adam_heat_cool.set_number.assert_called_with( - "056ee145a816487eaa69243c3280f8bf", "max_dhw_temperature", 55.0 - ) - - -async def test_adam_temperature_offset( - hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry -) -> None: - """Test creation of the temperature_offset number.""" - state = hass.states.get("number.zone_thermostat_jessie_temperature_offset") - assert state - assert float(state.state) == 0.0 - assert state.attributes.get("min") == -2.0 - assert state.attributes.get("max") == 2.0 - assert state.attributes.get("step") == 0.1 + """Test Adam number snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) async def test_adam_temperature_offset_change( @@ -123,3 +65,68 @@ async def test_adam_temperature_offset_out_of_bounds_change( }, blocking=True, ) + + +@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [False], indirect=True) +async def test_adam_dhw_setpoint_change( + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test changing of number entities.""" + state = hass.states.get("number.opentherm_domestic_hot_water_setpoint") + assert state + assert float(state.state) == 60.0 + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.opentherm_domestic_hot_water_setpoint", + ATTR_VALUE: 55, + }, + blocking=True, + ) + + assert mock_smile_adam_heat_cool.set_number.call_count == 1 + mock_smile_adam_heat_cool.set_number.assert_called_with( + "056ee145a816487eaa69243c3280f8bf", "max_dhw_temperature", 55.0 + ) + + +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) +@pytest.mark.parametrize("platforms", [(NUMBER_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_anna_number_entities( + hass: HomeAssistant, + mock_smile_anna: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test Anna number 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) +async def test_anna_max_boiler_temp_change( + hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test changing of number entities.""" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.opentherm_maximum_boiler_temperature_setpoint", + ATTR_VALUE: 65, + }, + blocking=True, + ) + + assert mock_smile_anna.set_number.call_count == 1 + mock_smile_anna.set_number.assert_called_with( + "1cbf783bb11e4a7c8a6843dee3a86927", "maximum_boiler_temperature", 65.0 + ) From 3cdb894e6175be28375d1297d64fb407f005c3e7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 22 Sep 2025 13:16:02 +0200 Subject: [PATCH 014/189] Small improvement of exposed_entities test (#152744) --- tests/components/homeassistant/test_exposed_entities.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py index ec87672e75c0..565fd7113bad 100644 --- a/tests/components/homeassistant/test_exposed_entities.py +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -105,6 +105,7 @@ async def test_load_preferences(hass: HomeAssistant) -> None: exposed_entities = hass.data[DATA_EXPOSED_ENTITIES] assert exposed_entities._assistants == {} + assert exposed_entities.entities == {} exposed_entities.async_set_expose_new_entities("test1", True) exposed_entities.async_set_expose_new_entities("test2", False) From a4f2c88c7f2c13e1670b9414dc323128e30c7c26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 22 Sep 2025 12:24:47 +0100 Subject: [PATCH 015/189] Add TriggerConfig to reduce ambiguity (#152563) --- .../components/zwave_js/triggers/event.py | 17 ++++++---- .../zwave_js/triggers/value_updated.py | 17 ++++++---- homeassistant/helpers/trigger.py | 33 ++++++++++++++----- tests/helpers/test_trigger.py | 11 ++++--- 4 files changed, 53 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index f7b76fa9a81c..6565e6983733 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable import functools +from typing import Any from pydantic import ValidationError import voluptuous as vol @@ -24,6 +25,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.trigger import ( Trigger, TriggerActionType, + TriggerConfig, TriggerData, TriggerInfo, move_top_level_schema_fields_to_options, @@ -126,7 +128,7 @@ class EventTrigger(Trigger): """Z-Wave JS event trigger.""" _hass: HomeAssistant - _options: ConfigType + _options: dict[str, Any] _event_source: str _event_name: str @@ -139,11 +141,13 @@ class EventTrigger(Trigger): @classmethod async def async_validate_complete_config( - cls, hass: HomeAssistant, config: ConfigType + cls, hass: HomeAssistant, complete_config: ConfigType ) -> ConfigType: """Validate complete config.""" - config = move_top_level_schema_fields_to_options(config, _OPTIONS_SCHEMA_DICT) - return await super().async_validate_complete_config(hass, config) + complete_config = move_top_level_schema_fields_to_options( + complete_config, _OPTIONS_SCHEMA_DICT + ) + return await super().async_validate_complete_config(hass, complete_config) @classmethod async def async_validate_config( @@ -170,10 +174,11 @@ class EventTrigger(Trigger): return config - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: """Initialize trigger.""" self._hass = hass - self._options = config[CONF_OPTIONS] + assert config.options is not None + self._options = config.options async def async_attach( self, diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py index 4a61cbba7232..14ab09961894 100644 --- a/homeassistant/components/zwave_js/triggers/value_updated.py +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable import functools +from typing import Any import voluptuous as vol from zwave_js_server.const import CommandClass @@ -23,6 +24,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.trigger import ( Trigger, TriggerActionType, + TriggerConfig, TriggerInfo, move_top_level_schema_fields_to_options, ) @@ -222,15 +224,17 @@ class ValueUpdatedTrigger(Trigger): """Z-Wave JS value updated trigger.""" _hass: HomeAssistant - _options: ConfigType + _options: dict[str, Any] @classmethod async def async_validate_complete_config( - cls, hass: HomeAssistant, config: ConfigType + cls, hass: HomeAssistant, complete_config: ConfigType ) -> ConfigType: """Validate complete config.""" - config = move_top_level_schema_fields_to_options(config, _OPTIONS_SCHEMA_DICT) - return await super().async_validate_complete_config(hass, config) + complete_config = move_top_level_schema_fields_to_options( + complete_config, _OPTIONS_SCHEMA_DICT + ) + return await super().async_validate_complete_config(hass, complete_config) @classmethod async def async_validate_config( @@ -239,10 +243,11 @@ class ValueUpdatedTrigger(Trigger): """Validate config.""" return await async_validate_trigger_config(hass, config) - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: """Initialize trigger.""" self._hass = hass - self._options = config[CONF_OPTIONS] + assert config.options is not None + self._options = config.options async def async_attach( self, diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index d949c9fdecb8..9ebd33678468 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -180,7 +180,7 @@ class Trigger(abc.ABC): @classmethod async def async_validate_complete_config( - cls, hass: HomeAssistant, config: ConfigType + cls, hass: HomeAssistant, complete_config: ConfigType ) -> ConfigType: """Validate complete config. @@ -189,19 +189,19 @@ class Trigger(abc.ABC): This method should be overridden by triggers that need to migrate from the old-style config. """ - config = _TRIGGER_SCHEMA(config) + complete_config = _TRIGGER_SCHEMA(complete_config) specific_config: ConfigType = {} for key in (CONF_OPTIONS, CONF_TARGET): - if key in config: - specific_config[key] = config.pop(key) + if key in complete_config: + specific_config[key] = complete_config.pop(key) specific_config = await cls.async_validate_config(hass, specific_config) for key in (CONF_OPTIONS, CONF_TARGET): if key in specific_config: - config[key] = specific_config[key] + complete_config[key] = specific_config[key] - return config + return complete_config @classmethod @abc.abstractmethod @@ -210,7 +210,7 @@ class Trigger(abc.ABC): ) -> ConfigType: """Validate config.""" - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: """Initialize trigger.""" @abc.abstractmethod @@ -248,6 +248,15 @@ class TriggerProtocol(Protocol): """Attach a trigger.""" +@dataclass(slots=True, frozen=True) +class TriggerConfig: + """Trigger config.""" + + key: str # The key used to identify the trigger, e.g. "zwave.event" + target: dict[str, Any] | None = None + options: dict[str, Any] | None = None + + class TriggerActionType(Protocol): """Protocol type for trigger action callback.""" @@ -552,7 +561,15 @@ async def async_initialize_triggers( relative_trigger_key = get_relative_description_key( platform_domain, trigger_key ) - trigger = trigger_descriptors[relative_trigger_key](hass, conf) + trigger_cls = trigger_descriptors[relative_trigger_key] + trigger = trigger_cls( + hass, + TriggerConfig( + key=trigger_key, + target=conf.get(CONF_TARGET), + options=conf.get(CONF_OPTIONS), + ), + ) coro = trigger.async_attach(action_wrapper, info) else: coro = platform.async_attach_trigger(hass, conf, action_wrapper, info) diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 876ba62396f5..7402cf2899f3 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -24,6 +24,7 @@ from homeassistant.helpers.trigger import ( PluggableAction, Trigger, TriggerActionType, + TriggerConfig, TriggerInfo, _async_get_trigger_platform, async_initialize_triggers, @@ -535,7 +536,7 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: """Validate config.""" return config - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: """Initialize trigger.""" class MockTrigger1(MockTrigger): @@ -612,13 +613,13 @@ async def test_platform_migrate_trigger(hass: HomeAssistant) -> None: @classmethod async def async_validate_complete_config( - cls, hass: HomeAssistant, config: ConfigType + cls, hass: HomeAssistant, complete_config: ConfigType ) -> ConfigType: """Validate complete config.""" - config = move_top_level_schema_fields_to_options( - config, OPTIONS_SCHEMA_DICT + complete_config = move_top_level_schema_fields_to_options( + complete_config, OPTIONS_SCHEMA_DICT ) - return await super().async_validate_complete_config(hass, config) + return await super().async_validate_complete_config(hass, complete_config) @classmethod async def async_validate_config( From 86dc453c5564a4519c258a439df333d84367f90c Mon Sep 17 00:00:00 2001 From: Jules Dejaeghere Date: Mon, 22 Sep 2025 13:28:41 +0200 Subject: [PATCH 016/189] Add integration for Belgian weather provider meteo.be (#144689) Co-authored-by: Joostlek --- CODEOWNERS | 2 + homeassistant/components/irm_kmi/__init__.py | 40 + .../components/irm_kmi/config_flow.py | 132 ++ homeassistant/components/irm_kmi/const.py | 102 + .../components/irm_kmi/coordinator.py | 95 + homeassistant/components/irm_kmi/data.py | 17 + homeassistant/components/irm_kmi/entity.py | 28 + .../components/irm_kmi/manifest.json | 13 + .../components/irm_kmi/quality_scale.yaml | 86 + homeassistant/components/irm_kmi/strings.json | 50 + homeassistant/components/irm_kmi/utils.py | 18 + homeassistant/components/irm_kmi/weather.py | 158 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/irm_kmi/__init__.py | 1 + tests/components/irm_kmi/conftest.py | 123 ++ .../components/irm_kmi/fixtures/forecast.json | 1474 +++++++++++++++ .../irm_kmi/fixtures/forecast_nl.json | 1355 ++++++++++++++ .../fixtures/forecast_out_of_benelux.json | 1625 ++++++++++++++++ .../irm_kmi/fixtures/high_low_temp.json | 1635 +++++++++++++++++ .../irm_kmi/snapshots/test_weather.ambr | 694 +++++++ tests/components/irm_kmi/test_config_flow.py | 154 ++ tests/components/irm_kmi/test_init.py | 43 + tests/components/irm_kmi/test_weather.py | 99 + 26 files changed, 7957 insertions(+) create mode 100644 homeassistant/components/irm_kmi/__init__.py create mode 100644 homeassistant/components/irm_kmi/config_flow.py create mode 100644 homeassistant/components/irm_kmi/const.py create mode 100644 homeassistant/components/irm_kmi/coordinator.py create mode 100644 homeassistant/components/irm_kmi/data.py create mode 100644 homeassistant/components/irm_kmi/entity.py create mode 100644 homeassistant/components/irm_kmi/manifest.json create mode 100644 homeassistant/components/irm_kmi/quality_scale.yaml create mode 100644 homeassistant/components/irm_kmi/strings.json create mode 100644 homeassistant/components/irm_kmi/utils.py create mode 100644 homeassistant/components/irm_kmi/weather.py create mode 100644 tests/components/irm_kmi/__init__.py create mode 100644 tests/components/irm_kmi/conftest.py create mode 100644 tests/components/irm_kmi/fixtures/forecast.json create mode 100644 tests/components/irm_kmi/fixtures/forecast_nl.json create mode 100644 tests/components/irm_kmi/fixtures/forecast_out_of_benelux.json create mode 100644 tests/components/irm_kmi/fixtures/high_low_temp.json create mode 100644 tests/components/irm_kmi/snapshots/test_weather.ambr create mode 100644 tests/components/irm_kmi/test_config_flow.py create mode 100644 tests/components/irm_kmi/test_init.py create mode 100644 tests/components/irm_kmi/test_weather.py diff --git a/CODEOWNERS b/CODEOWNERS index a0f5171dd495..0b6a1a8177f5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -772,6 +772,8 @@ build.json @home-assistant/supervisor /homeassistant/components/iqvia/ @bachya /tests/components/iqvia/ @bachya /homeassistant/components/irish_rail_transport/ @ttroy50 +/homeassistant/components/irm_kmi/ @jdejaegh +/tests/components/irm_kmi/ @jdejaegh /homeassistant/components/iron_os/ @tr4nt0r /tests/components/iron_os/ @tr4nt0r /homeassistant/components/isal/ @bdraco diff --git a/homeassistant/components/irm_kmi/__init__.py b/homeassistant/components/irm_kmi/__init__.py new file mode 100644 index 000000000000..3ca71f61cd63 --- /dev/null +++ b/homeassistant/components/irm_kmi/__init__.py @@ -0,0 +1,40 @@ +"""Integration for IRM KMI weather.""" + +import logging + +from irm_kmi_api import IrmKmiApiClientHa + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import IRM_KMI_TO_HA_CONDITION_MAP, PLATFORMS, USER_AGENT +from .coordinator import IrmKmiConfigEntry, IrmKmiCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: IrmKmiConfigEntry) -> bool: + """Set up this integration using UI.""" + api_client = IrmKmiApiClientHa( + session=async_get_clientsession(hass), + user_agent=USER_AGENT, + cdt_map=IRM_KMI_TO_HA_CONDITION_MAP, + ) + + entry.runtime_data = IrmKmiCoordinator(hass, entry, api_client) + + await entry.runtime_data.async_config_entry_first_refresh() + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: IrmKmiConfigEntry) -> bool: + """Handle removal of an entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_reload_entry(hass: HomeAssistant, entry: IrmKmiConfigEntry) -> None: + """Reload config entry.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/irm_kmi/config_flow.py b/homeassistant/components/irm_kmi/config_flow.py new file mode 100644 index 000000000000..ad426b36ba51 --- /dev/null +++ b/homeassistant/components/irm_kmi/config_flow.py @@ -0,0 +1,132 @@ +"""Config flow to set up IRM KMI integration via the UI.""" + +import logging + +from irm_kmi_api import IrmKmiApiClient, IrmKmiApiError +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlow, + OptionsFlowWithReload, +) +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_LOCATION, + CONF_UNIQUE_ID, +) +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + LocationSelector, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import ( + CONF_LANGUAGE_OVERRIDE, + CONF_LANGUAGE_OVERRIDE_OPTIONS, + DOMAIN, + OUT_OF_BENELUX, + USER_AGENT, +) +from .coordinator import IrmKmiConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +class IrmKmiConfigFlow(ConfigFlow, domain=DOMAIN): + """Configuration flow for the IRM KMI integration.""" + + VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow(_config_entry: IrmKmiConfigEntry) -> OptionsFlow: + """Create the options flow.""" + return IrmKmiOptionFlow() + + async def async_step_user(self, user_input: dict | None = None) -> ConfigFlowResult: + """Define the user step of the configuration flow.""" + errors: dict = {} + + default_location = { + ATTR_LATITUDE: self.hass.config.latitude, + ATTR_LONGITUDE: self.hass.config.longitude, + } + + if user_input: + _LOGGER.debug("Provided config user is: %s", user_input) + + lat: float = user_input[CONF_LOCATION][ATTR_LATITUDE] + lon: float = user_input[CONF_LOCATION][ATTR_LONGITUDE] + + try: + api_data = await IrmKmiApiClient( + session=async_get_clientsession(self.hass), + user_agent=USER_AGENT, + ).get_forecasts_coord({"lat": lat, "long": lon}) + except IrmKmiApiError: + _LOGGER.exception( + "Encountered an unexpected error while configuring the integration" + ) + return self.async_abort(reason="api_error") + + if api_data["cityName"] in OUT_OF_BENELUX: + errors[CONF_LOCATION] = "out_of_benelux" + + if not errors: + name: str = api_data["cityName"] + country: str = api_data["country"] + unique_id: str = f"{name.lower()} {country.lower()}" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + user_input[CONF_UNIQUE_ID] = unique_id + + return self.async_create_entry(title=name, data=user_input) + + default_location = user_input[CONF_LOCATION] + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_LOCATION, default=default_location + ): LocationSelector() + } + ), + errors=errors, + ) + + +class IrmKmiOptionFlow(OptionsFlowWithReload): + """Option flow for the IRM KMI integration, help change the options once the integration was configured.""" + + async def async_step_init(self, user_input: dict | None = None) -> ConfigFlowResult: + """Manage the options.""" + if user_input is not None: + _LOGGER.debug("Provided config user is: %s", user_input) + return self.async_create_entry(data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_LANGUAGE_OVERRIDE, + default=self.config_entry.options.get( + CONF_LANGUAGE_OVERRIDE, "none" + ), + ): SelectSelector( + SelectSelectorConfig( + options=CONF_LANGUAGE_OVERRIDE_OPTIONS, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_LANGUAGE_OVERRIDE, + ) + ) + } + ), + ) diff --git a/homeassistant/components/irm_kmi/const.py b/homeassistant/components/irm_kmi/const.py new file mode 100644 index 000000000000..afffc0fd2429 --- /dev/null +++ b/homeassistant/components/irm_kmi/const.py @@ -0,0 +1,102 @@ +"""Constants for the IRM KMI integration.""" + +from typing import Final + +from homeassistant.components.weather import ( + ATTR_CONDITION_CLEAR_NIGHT, + ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_FOG, + ATTR_CONDITION_LIGHTNING_RAINY, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_POURING, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SNOWY, + ATTR_CONDITION_SNOWY_RAINY, + ATTR_CONDITION_SUNNY, +) +from homeassistant.const import Platform, __version__ + +DOMAIN: Final = "irm_kmi" +PLATFORMS: Final = [Platform.WEATHER] + +OUT_OF_BENELUX: Final = [ + "außerhalb der Benelux (Brussels)", + "Hors de Belgique (Bxl)", + "Outside the Benelux (Brussels)", + "Buiten de Benelux (Brussel)", +] +LANGS: Final = ["en", "fr", "nl", "de"] + +CONF_LANGUAGE_OVERRIDE: Final = "language_override" +CONF_LANGUAGE_OVERRIDE_OPTIONS: Final = ["none", "fr", "nl", "de", "en"] + +# Dict to map ('ww', 'dayNight') tuple from IRM KMI to HA conditions. +IRM_KMI_TO_HA_CONDITION_MAP: Final = { + (0, "d"): ATTR_CONDITION_SUNNY, + (0, "n"): ATTR_CONDITION_CLEAR_NIGHT, + (1, "d"): ATTR_CONDITION_SUNNY, + (1, "n"): ATTR_CONDITION_CLEAR_NIGHT, + (2, "d"): ATTR_CONDITION_LIGHTNING_RAINY, + (2, "n"): ATTR_CONDITION_LIGHTNING_RAINY, + (3, "d"): ATTR_CONDITION_PARTLYCLOUDY, + (3, "n"): ATTR_CONDITION_PARTLYCLOUDY, + (4, "d"): ATTR_CONDITION_POURING, + (4, "n"): ATTR_CONDITION_POURING, + (5, "d"): ATTR_CONDITION_LIGHTNING_RAINY, + (5, "n"): ATTR_CONDITION_LIGHTNING_RAINY, + (6, "d"): ATTR_CONDITION_POURING, + (6, "n"): ATTR_CONDITION_POURING, + (7, "d"): ATTR_CONDITION_LIGHTNING_RAINY, + (7, "n"): ATTR_CONDITION_LIGHTNING_RAINY, + (8, "d"): ATTR_CONDITION_SNOWY_RAINY, + (8, "n"): ATTR_CONDITION_SNOWY_RAINY, + (9, "d"): ATTR_CONDITION_SNOWY_RAINY, + (9, "n"): ATTR_CONDITION_SNOWY_RAINY, + (10, "d"): ATTR_CONDITION_LIGHTNING_RAINY, + (10, "n"): ATTR_CONDITION_LIGHTNING_RAINY, + (11, "d"): ATTR_CONDITION_SNOWY, + (11, "n"): ATTR_CONDITION_SNOWY, + (12, "d"): ATTR_CONDITION_SNOWY, + (12, "n"): ATTR_CONDITION_SNOWY, + (13, "d"): ATTR_CONDITION_LIGHTNING_RAINY, + (13, "n"): ATTR_CONDITION_LIGHTNING_RAINY, + (14, "d"): ATTR_CONDITION_CLOUDY, + (14, "n"): ATTR_CONDITION_CLOUDY, + (15, "d"): ATTR_CONDITION_CLOUDY, + (15, "n"): ATTR_CONDITION_CLOUDY, + (16, "d"): ATTR_CONDITION_POURING, + (16, "n"): ATTR_CONDITION_POURING, + (17, "d"): ATTR_CONDITION_LIGHTNING_RAINY, + (17, "n"): ATTR_CONDITION_LIGHTNING_RAINY, + (18, "d"): ATTR_CONDITION_RAINY, + (18, "n"): ATTR_CONDITION_RAINY, + (19, "d"): ATTR_CONDITION_POURING, + (19, "n"): ATTR_CONDITION_POURING, + (20, "d"): ATTR_CONDITION_SNOWY_RAINY, + (20, "n"): ATTR_CONDITION_SNOWY_RAINY, + (21, "d"): ATTR_CONDITION_RAINY, + (21, "n"): ATTR_CONDITION_RAINY, + (22, "d"): ATTR_CONDITION_SNOWY, + (22, "n"): ATTR_CONDITION_SNOWY, + (23, "d"): ATTR_CONDITION_SNOWY, + (23, "n"): ATTR_CONDITION_SNOWY, + (24, "d"): ATTR_CONDITION_FOG, + (24, "n"): ATTR_CONDITION_FOG, + (25, "d"): ATTR_CONDITION_FOG, + (25, "n"): ATTR_CONDITION_FOG, + (26, "d"): ATTR_CONDITION_FOG, + (26, "n"): ATTR_CONDITION_FOG, + (27, "d"): ATTR_CONDITION_FOG, + (27, "n"): ATTR_CONDITION_FOG, +} + +IRM_KMI_NAME: Final = { + "fr": "Institut Royal Météorologique de Belgique", + "nl": "Koninklijk Meteorologisch Instituut van België", + "de": "Königliche Meteorologische Institut von Belgien", + "en": "Royal Meteorological Institute of Belgium", +} + +USER_AGENT: Final = ( + f"https://www.home-assistant.io/integrations/irm_kmi (version {__version__})" +) diff --git a/homeassistant/components/irm_kmi/coordinator.py b/homeassistant/components/irm_kmi/coordinator.py new file mode 100644 index 000000000000..9ff6d735cddb --- /dev/null +++ b/homeassistant/components/irm_kmi/coordinator.py @@ -0,0 +1,95 @@ +"""DataUpdateCoordinator for the IRM KMI integration.""" + +from datetime import timedelta +import logging + +from irm_kmi_api import IrmKmiApiClientHa, IrmKmiApiError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_LOCATION +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import ( + TimestampDataUpdateCoordinator, + UpdateFailed, +) +from homeassistant.util import dt as dt_util +from homeassistant.util.dt import utcnow + +from .data import ProcessedCoordinatorData +from .utils import preferred_language + +_LOGGER = logging.getLogger(__name__) + +type IrmKmiConfigEntry = ConfigEntry[IrmKmiCoordinator] + + +class IrmKmiCoordinator(TimestampDataUpdateCoordinator[ProcessedCoordinatorData]): + """Coordinator to update data from IRM KMI.""" + + def __init__( + self, + hass: HomeAssistant, + entry: IrmKmiConfigEntry, + api_client: IrmKmiApiClientHa, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name="IRM KMI weather", + update_interval=timedelta(minutes=7), + ) + self._api = api_client + self._location = entry.data[CONF_LOCATION] + + async def _async_update_data(self) -> ProcessedCoordinatorData: + """Fetch data from API endpoint. + + This is the place to pre-process the data to lookup tables so entities can quickly look up their data. + :return: ProcessedCoordinatorData + """ + + self._api.expire_cache() + + try: + await self._api.refresh_forecasts_coord( + { + "lat": self._location[ATTR_LATITUDE], + "long": self._location[ATTR_LONGITUDE], + } + ) + + except IrmKmiApiError as err: + if ( + self.last_update_success_time is not None + and self.update_interval is not None + and self.last_update_success_time - utcnow() + < timedelta(seconds=2.5 * self.update_interval.seconds) + ): + return self.data + + _LOGGER.warning( + "Could not connect to the API since %s", self.last_update_success_time + ) + raise UpdateFailed( + f"Error communicating with API for general forecast: {err}. " + f"Last success time is: {self.last_update_success_time}" + ) from err + + if not self.last_update_success: + _LOGGER.warning("Successfully reconnected to the API") + + return await self.process_api_data() + + async def process_api_data(self) -> ProcessedCoordinatorData: + """From the API data, create the object that will be used in the entities.""" + tz = await dt_util.async_get_time_zone("Europe/Brussels") + lang = preferred_language(self.hass, self.config_entry) + + return ProcessedCoordinatorData( + current_weather=self._api.get_current_weather(tz), + daily_forecast=self._api.get_daily_forecast(tz, lang), + hourly_forecast=self._api.get_hourly_forecast(tz), + country=self._api.get_country(), + ) diff --git a/homeassistant/components/irm_kmi/data.py b/homeassistant/components/irm_kmi/data.py new file mode 100644 index 000000000000..5a70b97f36f1 --- /dev/null +++ b/homeassistant/components/irm_kmi/data.py @@ -0,0 +1,17 @@ +"""Define data classes for the IRM KMI integration.""" + +from dataclasses import dataclass, field + +from irm_kmi_api import CurrentWeatherData, ExtendedForecast + +from homeassistant.components.weather import Forecast + + +@dataclass +class ProcessedCoordinatorData: + """Dataclass that will be exposed to the entities consuming data from an IrmKmiCoordinator.""" + + current_weather: CurrentWeatherData + country: str + hourly_forecast: list[Forecast] = field(default_factory=list) + daily_forecast: list[ExtendedForecast] = field(default_factory=list) diff --git a/homeassistant/components/irm_kmi/entity.py b/homeassistant/components/irm_kmi/entity.py new file mode 100644 index 000000000000..a35c04ac4259 --- /dev/null +++ b/homeassistant/components/irm_kmi/entity.py @@ -0,0 +1,28 @@ +"""Base class shared among IRM KMI entities.""" + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, IRM_KMI_NAME +from .coordinator import IrmKmiConfigEntry, IrmKmiCoordinator +from .utils import preferred_language + + +class IrmKmiBaseEntity(CoordinatorEntity[IrmKmiCoordinator]): + """Base methods for IRM KMI entities.""" + + _attr_attribution = ( + "Weather data from the Royal Meteorological Institute of Belgium meteo.be" + ) + _attr_has_entity_name = True + + def __init__(self, entry: IrmKmiConfigEntry) -> None: + """Init base properties for IRM KMI entities.""" + coordinator = entry.runtime_data + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer=IRM_KMI_NAME.get(preferred_language(self.hass, entry)), + ) diff --git a/homeassistant/components/irm_kmi/manifest.json b/homeassistant/components/irm_kmi/manifest.json new file mode 100644 index 000000000000..f79819f5e836 --- /dev/null +++ b/homeassistant/components/irm_kmi/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "irm_kmi", + "name": "IRM KMI Weather Belgium", + "codeowners": ["@jdejaegh"], + "config_flow": true, + "dependencies": ["zone"], + "documentation": "https://www.home-assistant.io/integrations/irm_kmi", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["irm_kmi_api"], + "quality_scale": "bronze", + "requirements": ["irm-kmi-api==1.1.0"] +} diff --git a/homeassistant/components/irm_kmi/quality_scale.yaml b/homeassistant/components/irm_kmi/quality_scale.yaml new file mode 100644 index 000000000000..15e34719025d --- /dev/null +++ b/homeassistant/components/irm_kmi/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: > + No service action implemented in this integration at the moment. + appropriate-polling: + status: done + comment: > + Polling interval is set to 7 minutes. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: > + No service action implemented in this integration at the moment. + 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 service action implemented in this integration at the moment. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: > + There is no authentication for this integration + test-coverage: todo + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: > + The integration does not look for devices on the network. It uses an online API. + discovery: + status: exempt + comment: > + The integration does not look for devices on the network. It uses an online API. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: > + This integration does not integrate physical devices. + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: done + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: + status: exempt + comment: > + There is no configuration per se, just a zone to pick. + repair-issues: done + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/irm_kmi/strings.json b/homeassistant/components/irm_kmi/strings.json new file mode 100644 index 000000000000..810b61fc2763 --- /dev/null +++ b/homeassistant/components/irm_kmi/strings.json @@ -0,0 +1,50 @@ +{ + "title": "Royal Meteorological Institute of Belgium", + "common": { + "language_override_description": "Override the Home Assistant language for the textual weather forecast." + }, + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", + "api_error": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "location": "[%key:common::config_flow::data::location%]" + }, + "data_description": { + "location": "[%key:common::config_flow::data::location%]" + } + } + }, + "error": { + "out_of_benelux": "The location is outside of Benelux. Pick a location in Benelux." + } + }, + "selector": { + "language_override": { + "options": { + "none": "Follow Home Assistant server language", + "fr": "French", + "nl": "Dutch", + "de": "German", + "en": "English" + } + } + }, + "options": { + "step": { + "init": { + "title": "Options", + "data": { + "language_override": "[%key:common::config_flow::data::language%]" + }, + "data_description": { + "language_override": "[%key:component::irm_kmi::common::language_override_description%]" + } + } + } + } +} diff --git a/homeassistant/components/irm_kmi/utils.py b/homeassistant/components/irm_kmi/utils.py new file mode 100644 index 000000000000..b5f362976963 --- /dev/null +++ b/homeassistant/components/irm_kmi/utils.py @@ -0,0 +1,18 @@ +"""Helper functions for use with IRM KMI integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import CONF_LANGUAGE_OVERRIDE, LANGS + + +def preferred_language(hass: HomeAssistant, config_entry: ConfigEntry | None) -> str: + """Get the preferred language for the integration if it was overridden by the configuration.""" + + if ( + config_entry is None + or config_entry.options.get(CONF_LANGUAGE_OVERRIDE) == "none" + ): + return hass.config.language if hass.config.language in LANGS else "en" + + return config_entry.options.get(CONF_LANGUAGE_OVERRIDE, "en") diff --git a/homeassistant/components/irm_kmi/weather.py b/homeassistant/components/irm_kmi/weather.py new file mode 100644 index 000000000000..a0b4286a50c4 --- /dev/null +++ b/homeassistant/components/irm_kmi/weather.py @@ -0,0 +1,158 @@ +"""Support for IRM KMI weather.""" + +from irm_kmi_api import CurrentWeatherData + +from homeassistant.components.weather import ( + Forecast, + SingleCoordinatorWeatherEntity, + WeatherEntityFeature, +) +from homeassistant.const import ( + CONF_UNIQUE_ID, + UnitOfPrecipitationDepth, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import IrmKmiConfigEntry, IrmKmiCoordinator +from .entity import IrmKmiBaseEntity + + +async def async_setup_entry( + _hass: HomeAssistant, + entry: IrmKmiConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the weather entry.""" + async_add_entities([IrmKmiWeather(entry)]) + + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +class IrmKmiWeather( + IrmKmiBaseEntity, # WeatherEntity + SingleCoordinatorWeatherEntity[IrmKmiCoordinator], +): + """Weather entity for IRM KMI weather.""" + + _attr_name = None + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_DAILY + | WeatherEntityFeature.FORECAST_TWICE_DAILY + | WeatherEntityFeature.FORECAST_HOURLY + ) + _attr_native_temperature_unit = UnitOfTemperature.CELSIUS + _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS + _attr_native_pressure_unit = UnitOfPressure.HPA + + def __init__(self, entry: IrmKmiConfigEntry) -> None: + """Create a new instance of the weather entity from a configuration entry.""" + IrmKmiBaseEntity.__init__(self, entry) + SingleCoordinatorWeatherEntity.__init__(self, entry.runtime_data) + self._attr_unique_id = entry.data[CONF_UNIQUE_ID] + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available + + @property + def current_weather(self) -> CurrentWeatherData: + """Return the current weather.""" + return self.coordinator.data.current_weather + + @property + def condition(self) -> str | None: + """Return the current condition.""" + return self.current_weather.get("condition") + + @property + def native_temperature(self) -> float | None: + """Return the temperature in native units.""" + return self.current_weather.get("temperature") + + @property + def native_wind_speed(self) -> float | None: + """Return the wind speed in native units.""" + return self.current_weather.get("wind_speed") + + @property + def native_wind_gust_speed(self) -> float | None: + """Return the wind gust speed in native units.""" + return self.current_weather.get("wind_gust_speed") + + @property + def wind_bearing(self) -> float | str | None: + """Return the wind bearing.""" + return self.current_weather.get("wind_bearing") + + @property + def native_pressure(self) -> float | None: + """Return the pressure in native units.""" + return self.current_weather.get("pressure") + + @property + def uv_index(self) -> float | None: + """Return the UV index.""" + return self.current_weather.get("uv_index") + + def _async_forecast_twice_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + return self.coordinator.data.daily_forecast + + def _async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + return self.daily_forecast() + + def _async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast in native units.""" + return self.coordinator.data.hourly_forecast + + def daily_forecast(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + data: list[Forecast] = self.coordinator.data.daily_forecast + + # The data in daily_forecast might contain nighttime forecast. + # The following handle the lowest temperature attribute to be displayed correctly. + if ( + len(data) > 1 + and not data[0].get("is_daytime") + and data[1].get("native_templow") is None + ): + data[1]["native_templow"] = data[0].get("native_templow") + if ( + data[1]["native_templow"] is not None + and data[1]["native_temperature"] is not None + and data[1]["native_templow"] > data[1]["native_temperature"] + ): + (data[1]["native_templow"], data[1]["native_temperature"]) = ( + data[1]["native_temperature"], + data[1]["native_templow"], + ) + + if len(data) > 0 and not data[0].get("is_daytime"): + return data + + if ( + len(data) > 1 + and data[0].get("native_templow") is None + and not data[1].get("is_daytime") + ): + data[0]["native_templow"] = data[1].get("native_templow") + if ( + data[0]["native_templow"] is not None + and data[0]["native_temperature"] is not None + and data[0]["native_templow"] > data[0]["native_temperature"] + ): + (data[0]["native_templow"], data[0]["native_temperature"]) = ( + data[0]["native_temperature"], + data[0]["native_templow"], + ) + + return [f for f in data if f.get("is_daytime")] diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 552092915316..a3b7aa63060f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -310,6 +310,7 @@ FLOWS = { "ipma", "ipp", "iqvia", + "irm_kmi", "iron_os", "iskra", "islamic_prayer_times", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 0591305fa085..1b72bed62b96 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3118,6 +3118,11 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "irm_kmi": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "iron_os": { "name": "IronOS", "integration_type": "hub", @@ -7969,6 +7974,7 @@ "input_select", "input_text", "integration", + "irm_kmi", "islamic_prayer_times", "local_calendar", "local_ip", diff --git a/requirements_all.txt b/requirements_all.txt index 45285b21df02..dccd226ac18c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1278,6 +1278,9 @@ iottycloud==0.3.0 # homeassistant.components.iperf3 iperf3==0.1.11 +# homeassistant.components.irm_kmi +irm-kmi-api==1.1.0 + # homeassistant.components.isal isal==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ca7601da1fe..be4803bd890c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1109,6 +1109,9 @@ iometer==0.1.0 # homeassistant.components.iotty iottycloud==0.3.0 +# homeassistant.components.irm_kmi +irm-kmi-api==1.1.0 + # homeassistant.components.isal isal==1.8.0 diff --git a/tests/components/irm_kmi/__init__.py b/tests/components/irm_kmi/__init__.py new file mode 100644 index 000000000000..629c80d5d9e8 --- /dev/null +++ b/tests/components/irm_kmi/__init__.py @@ -0,0 +1 @@ +"""Tests of IRM KMI integration.""" diff --git a/tests/components/irm_kmi/conftest.py b/tests/components/irm_kmi/conftest.py new file mode 100644 index 000000000000..b3ef4fa1b891 --- /dev/null +++ b/tests/components/irm_kmi/conftest.py @@ -0,0 +1,123 @@ +"""Fixtures for the IRM KMI integration tests.""" + +from collections.abc import Generator +import json +from unittest.mock import MagicMock, patch + +from irm_kmi_api import IrmKmiApiError +import pytest + +from homeassistant.components.irm_kmi.const import DOMAIN +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_LOCATION, + CONF_UNIQUE_ID, +) + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Home", + domain=DOMAIN, + data={ + CONF_LOCATION: {ATTR_LATITUDE: 50.84, ATTR_LONGITUDE: 4.35}, + CONF_UNIQUE_ID: "city country", + }, + unique_id="50.84-4.35", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[None]: + """Mock setting up a config entry.""" + with patch("homeassistant.components.irm_kmi.async_setup_entry", return_value=True): + yield + + +@pytest.fixture +def mock_get_forecast_in_benelux(): + """Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it returns something valid and in the Benelux.""" + with patch( + "homeassistant.components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord", + return_value={"cityName": "Brussels", "country": "BE"}, + ): + yield + + +@pytest.fixture +def mock_get_forecast_out_benelux_then_in_belgium(): + """Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it returns something outside Benelux.""" + with patch( + "homeassistant.components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord", + side_effect=[ + {"cityName": "Outside the Benelux (Brussels)", "country": "BE"}, + {"cityName": "Brussels", "country": "BE"}, + ], + ): + yield + + +@pytest.fixture +def mock_get_forecast_api_error(): + """Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it raises an error.""" + with patch( + "homeassistant.components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord", + side_effect=IrmKmiApiError, + ): + yield + + +@pytest.fixture +def mock_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock]: + """Return a mocked IrmKmi api client.""" + fixture: str = "forecast.json" + + forecast = json.loads(load_fixture(fixture, "irm_kmi")) + with patch( + "homeassistant.components.irm_kmi.IrmKmiApiClientHa", autospec=True + ) as irm_kmi_api_mock: + irm_kmi = irm_kmi_api_mock.return_value + irm_kmi.get_forecasts_coord.return_value = forecast + yield irm_kmi + + +@pytest.fixture +def mock_irm_kmi_api_nl(): + """Mock a call to IrmKmiApiClientHa.get_forecasts_coord() to return a forecast in The Netherlands.""" + fixture: str = "forecast_nl.json" + forecast = json.loads(load_fixture(fixture, "irm_kmi")) + with patch( + "homeassistant.components.irm_kmi.coordinator.IrmKmiApiClientHa.get_forecasts_coord", + return_value=forecast, + ): + yield + + +@pytest.fixture +def mock_irm_kmi_api_high_low_temp(): + """Mock a call to IrmKmiApiClientHa.get_forecasts_coord() to return high_low_temp.json forecast.""" + fixture: str = "high_low_temp.json" + forecast = json.loads(load_fixture(fixture, "irm_kmi")) + with patch( + "homeassistant.components.irm_kmi.coordinator.IrmKmiApiClientHa.get_forecasts_coord", + return_value=forecast, + ): + yield + + +@pytest.fixture +def mock_exception_irm_kmi_api( + request: pytest.FixtureRequest, +) -> Generator[None, MagicMock]: + """Return a mocked IrmKmi api client that will raise an error upon refreshing data.""" + with patch( + "homeassistant.components.irm_kmi.IrmKmiApiClientHa", autospec=True + ) as irm_kmi_api_mock: + irm_kmi = irm_kmi_api_mock.return_value + irm_kmi.refresh_forecasts_coord.side_effect = IrmKmiApiError + yield irm_kmi diff --git a/tests/components/irm_kmi/fixtures/forecast.json b/tests/components/irm_kmi/fixtures/forecast.json new file mode 100644 index 000000000000..06b8f3d81d7d --- /dev/null +++ b/tests/components/irm_kmi/fixtures/forecast.json @@ -0,0 +1,1474 @@ +{ + "cityName": "Namur", + "country": "BE", + "obs": { + "temp": 7, + "timestamp": "2023-12-26T18:30:00+01:00", + "ww": 15, + "dayNight": "n" + }, + "for": { + "daily": [ + { + "dayName": { + "fr": "Cette nuit", + "nl": "Vannacht", + "en": "Tonight", + "de": "heute abend" + }, + "period": "2", + "day_night": "0", + "dayNight": "n", + "text": { + "nl": "Vanavond verloopt droog, maar geleidelijk neemt ook de middelhoge bewolking toe. Vannacht verschijnen er alsmaar meer lage wolkenvelden. Vooral in de Ardennen kan er wat nevel en mist gevormd worden, waardoor het zicht bij momenten slecht is. Na middernacht begint het licht te regenen vanaf de Franse grens. De minima worden vroeg bereikt en liggen rond 0 of +1 graad op de hoogste toppen en tussen 3 en 6 graden in de meeste andere streken. De zwakke wind uit zuidwest krimpt naar het zuiden tot zuidoosten en wordt aan het einde van de nacht overal matig.", + "fr": "Ce soir, le temps restera sec même si des nuages moyens gagneront également notre territoire. Cette nuit, le ciel finira par se couvrir avec l'arrivée de nuages de plus basse altitude. Principalement en Ardenne, un peu de brume et de brouillard pourra se former, ce qui réduira parfois la visibilité. Après minuit, de faibles pluies se produiront depuis la frontière française. Les minima, atteints rapidement, se situeront autour de 0 ou +1 degré sur le relief et entre +3 et +6 degrés ailleurs. Le vent sera faible de secteur sud-ouest et deviendra le plus souvent modéré en fin de nuit." + }, + "dawnRiseSeconds": "31440", + "dawnSetSeconds": "60120", + "tempMin": 4, + "tempMax": null, + "ww1": 14, + "ww2": 19, + "wwevol": 0, + "ff1": 2, + "ff2": 3, + "ffevol": 0, + "dd": 0, + "ddText": { + "fr": "S", + "nl": "Z", + "en": "S", + "de": "S" + }, + "wind": { + "speed": 6, + "peakSpeed": null, + "dir": 0, + "dirText": { + "fr": "S", + "nl": "Z", + "en": "S", + "de": "S" + } + }, + "precipChance": 95, + "precipQuantity": "2" + }, + { + "dayName": { + "fr": "Mercredi", + "nl": "Woensdag", + "en": "Wednesday", + "de": "Mittwoch" + }, + "period": "3", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Foo", + "fr": "Bar", + "en": "Hey!" + }, + "dawnRiseSeconds": "31440", + "dawnSetSeconds": "60180", + "tempMin": 4, + "tempMax": 9, + "ww1": 3, + "ww2": null, + "wwevol": null, + "ff1": 4, + "ff2": null, + "ffevol": null, + "dd": 0, + "ddText": { + "fr": "S", + "nl": "Z", + "en": "S", + "de": "S" + }, + "wind": { + "speed": 20, + "peakSpeed": "50", + "dir": 0, + "dirText": { + "fr": "S", + "nl": "Z", + "en": "S", + "de": "S" + } + }, + "precipChance": 0, + "precipQuantity": "0" + }, + { + "dayName": { + "fr": "Jeudi", + "nl": "Donderdag", + "en": "Thursday", + "de": "Donnerstag" + }, + "period": "5", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Donderdag wisselen opklaringen en wolken elkaar af, waaruit plaatselijk enkele buien vallen. Aan het begin van de dag hangt er in de Ardennen veel bewolking met wat laatste lichte regen. Aan het einde van de dag bereiken iets meer buien de kuststreek, om nadien tijdens de daaropvolgende nacht op te schuiven naar het binnenland. Het is vrij winderig en zeer zacht met maxima van 7 graden in de Hoge Ardennen tot 11 graden over het westen van het land. De zuidwestenwind is matig tot vrij krachtig en aan zee soms krachtig met windstoten tot 60 of 70 km/h.", + "fr": "Jeudi, nuages et éclaircies se partageront le ciel avec quelques averses isolées. En début de journée, les nuages pourraient encore s'accrocher sur l'Ardenne avec quelques faibles pluies résiduelles. En fin de journée, des averses un peu plus nombreuses devraient aborder la région littorale, puis traverser notre pays au cours de la nuit suivante. Le temps sera assez venteux et très doux avec des maxima de 7 degrés en haute Ardenne à 11 degrés sur l'ouest du pays. Le vent de sud-ouest sera modéré à assez fort, le long du littoral parfois fort. Les rafales pourront atteindre 60 à 70 km/h." + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60180", + "tempMin": 7, + "tempMax": 10, + "ww1": 3, + "ww2": null, + "wwevol": null, + "ff1": 5, + "ff2": 4, + "ffevol": 1, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 29, + "peakSpeed": "60", + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 0, + "precipQuantity": "0" + }, + { + "dayName": { + "fr": "Vendredi", + "nl": "Vrijdag", + "en": "Friday", + "de": "Freitag" + }, + "period": "7", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Vrijdag is het zacht en winderig. Bij momenten vallen er intense regenbuien. De maxima klimmen naar waarden tussen 6 en 10 graden bij een vrij krachtige en aan zee soms krachtige zuidwestenwind. Rukwinden zijn mogelijk tot 60 of 70 km/h.", + "fr": "Vendredi, le temps sera doux et venteux. De nouvelles pluies parfois importantes et sous forme d'averses traverseront notre pays. Les maxima varieront entre 6 et 10 degrés avec un vent assez fort de sud-ouest, le long du littoral parfois fort. Les rafales atteindront 60 à 70 km/h." + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60240", + "tempMin": 8, + "tempMax": 9, + "ww1": 6, + "ww2": 19, + "wwevol": 0, + "ff1": 5, + "ff2": null, + "ffevol": null, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 29, + "peakSpeed": "65", + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 100, + "precipQuantity": "8" + }, + { + "dayName": { + "fr": "Samedi", + "nl": "Zaterdag", + "en": "Saturday", + "de": "Samstag" + }, + "period": "9", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Zaterdag wordt het onstabieler. We krijgen bewolkte perioden te verwerken, die soms plaats maken voor enkele zonnige momenten. Het wordt droger, maar toch blijven enkele buien nog steeds mogelijk. De maxima liggen tussen 5 en 9 graden. De westen- tot zuidwestenwind neemt tijdelijk af in kracht, maar blijft in de kustregio vrij krachtig waaien.", + "fr": "Samedi, nous passerons sous un régime plus variable où les passages nuageux laisseront par moments entrevoir quelques rayons de soleil. Il fera plus sec mais quelques averses resteront encore possibles. Les maxima varieront entre 5 et 9 degrés. Le vent d'ouest à sud-ouest diminuera temporairement mais restera encore assez soutenu le long du littoral." + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60300", + "tempMin": 4, + "tempMax": 8, + "ww1": 1, + "ww2": 15, + "wwevol": 0, + "ff1": 4, + "ff2": 5, + "ffevol": 0, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 20, + "peakSpeed": "55", + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 50, + "precipQuantity": "0" + }, + { + "dayName": { + "fr": "Dimanche", + "nl": "Zondag", + "en": "Sunday", + "de": "Sonntag" + }, + "period": "11", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Zondag trekt een nieuwe actieve regenzone over ons land. Deze wordt aangedreven door een krachtige zuidwestenwind. Na zijn doortocht draait de wind naar het noordwesten en wordt het frisser en onstabieler met buien. We halen maxima van 6 tot 10 graden.", + "fr": "Dimanche, une nouvelle zone de pluie active traversera notre pays, poussée par un vigoureux vent de sud-ouest. Après son passage, le vent basculera au nord-ouest et de l'air plus frais et plus instable accompagné d'averses envahira notre pays. Les maxima varieront entre 6 et 10 degrés." + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60360", + "tempMin": 8, + "tempMax": 8, + "ww1": 19, + "ww2": null, + "wwevol": null, + "ff1": 6, + "ff2": 5, + "ffevol": 0, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 29, + "peakSpeed": "85", + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 100, + "precipQuantity": "9" + }, + { + "dayName": { + "fr": "Lundi", + "nl": "Maandag", + "en": "Monday", + "de": "Montag" + }, + "period": "13", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Op Nieuwjaarsdag lijkt het rustiger te worden met minder regen en minder wind. Het wordt iets frisser met maxima tussen 3 en 7 graden.", + "fr": "Le jour de l'an devrait connaître une accalmie passagère avec moins de pluie et de vent. Il fera un peu plus frais avec des maxima de 3 à 7 degrés." + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60420", + "tempMin": 5, + "tempMax": 6, + "ww1": 18, + "ww2": 19, + "wwevol": 0, + "ff1": 1, + "ff2": 3, + "ffevol": 0, + "dd": 315, + "ddText": { + "fr": "SE", + "nl": "ZO", + "en": "SE", + "de": "SO" + }, + "wind": { + "speed": 12, + "peakSpeed": null, + "dir": 315, + "dirText": { + "fr": "SE", + "nl": "ZO", + "en": "SE", + "de": "SO" + } + }, + "precipChance": 80, + "precipQuantity": "3" + }, + { + "dayName": { + "fr": "Mardi", + "nl": "Dinsdag", + "en": "Tuesday", + "de": "Dienstag" + }, + "period": "15", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Volgende week dinsdag trekt een nieuwe regenzone over ons land. De maxima liggen tussen 5 en 9 graden.", + "fr": "Mardi prochain, une nouvelle zone de pluie devrait traverser notre pays. Les maxima varieront entre 5 et 9 degrés." + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60480", + "tempMin": 5, + "tempMax": 9, + "ww1": 19, + "ww2": null, + "wwevol": null, + "ff1": 5, + "ff2": null, + "ffevol": null, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 29, + "peakSpeed": "75", + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 100, + "precipQuantity": "7" + } + ], + "showWarningTab": false, + "graph": { + "svg": [ + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tx&l=nl&k=782832cc606de3bad9b7f2002de4b4b1", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tx&l=fr&k=782832cc606de3bad9b7f2002de4b4b1", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tx&l=en&k=782832cc606de3bad9b7f2002de4b4b1", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tx&l=de&k=782832cc606de3bad9b7f2002de4b4b1" + }, + "ratio": 1.3638709677419354 + }, + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tn&l=nl&k=782832cc606de3bad9b7f2002de4b4b1", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tn&l=fr&k=782832cc606de3bad9b7f2002de4b4b1", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tn&l=en&k=782832cc606de3bad9b7f2002de4b4b1", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tn&l=de&k=782832cc606de3bad9b7f2002de4b4b1" + }, + "ratio": 1.3638709677419354 + }, + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=rr&l=nl&k=782832cc606de3bad9b7f2002de4b4b1", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=rr&l=fr&k=782832cc606de3bad9b7f2002de4b4b1", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=rr&l=en&k=782832cc606de3bad9b7f2002de4b4b1", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=rr&l=de&k=782832cc606de3bad9b7f2002de4b4b1" + }, + "ratio": 1.3638709677419354 + } + ] + }, + "hourly": [ + { + "hour": "18", + "temp": 7, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1020, + "windSpeedKm": 5, + "windPeakSpeedKm": null, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "19", + "temp": 6, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1021, + "windSpeedKm": 5, + "windPeakSpeedKm": null, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "20", + "temp": 5, + "ww": "14", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1021, + "windSpeedKm": 5, + "windPeakSpeedKm": null, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "21", + "temp": 4, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1021, + "windSpeedKm": 5, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "22", + "temp": 4, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0.01, + "pressure": 1021, + "windSpeedKm": 5, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "23", + "temp": 5, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1021, + "windSpeedKm": 10, + "windPeakSpeedKm": null, + "windDirection": 338, + "windDirectionText": { + "nl": "ZZO", + "fr": "SSE", + "en": "SSE", + "de": "SSO" + }, + "dayNight": "n" + }, + { + "hour": "00", + "temp": 7, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1021, + "windSpeedKm": 10, + "windPeakSpeedKm": null, + "windDirection": 338, + "windDirectionText": { + "nl": "ZZO", + "fr": "SSE", + "en": "SSE", + "de": "SSO" + }, + "dayNight": "n", + "dateShow": "27/12", + "dateShowLocalized": { + "nl": "Woe.", + "fr": "Mer.", + "en": "Wed.", + "de": "Mit." + } + }, + { + "hour": "01", + "temp": 8, + "ww": "15", + "precipChance": "30", + "precipQuantity": 0.01, + "pressure": 1020, + "windSpeedKm": 15, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "02", + "temp": 8, + "ww": "18", + "precipChance": "70", + "precipQuantity": 0.98, + "pressure": 1020, + "windSpeedKm": 15, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "03", + "temp": 8, + "ww": "18", + "precipChance": "90", + "precipQuantity": 1.14, + "pressure": 1019, + "windSpeedKm": 15, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "04", + "temp": 9, + "ww": "18", + "precipChance": "70", + "precipQuantity": 0.15, + "pressure": 1019, + "windSpeedKm": 15, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "05", + "temp": 9, + "ww": "15", + "precipChance": "30", + "precipQuantity": 0, + "pressure": 1018, + "windSpeedKm": 20, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "06", + "temp": 9, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0.01, + "pressure": 1018, + "windSpeedKm": 15, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "07", + "temp": 9, + "ww": "14", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1018, + "windSpeedKm": 20, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "08", + "temp": 8, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1017, + "windSpeedKm": 20, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "09", + "temp": 7, + "ww": "14", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1017, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "10", + "temp": 7, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1017, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "11", + "temp": 7, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1017, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "12", + "temp": 8, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1016, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "13", + "temp": 8, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1015, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "14", + "temp": 8, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1014, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "15", + "temp": 8, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 30, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "16", + "temp": 8, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 25, + "windPeakSpeedKm": 50, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "17", + "temp": 7, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "18", + "temp": 7, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "19", + "temp": 8, + "ww": "3", + "precipChance": "20", + "precipQuantity": 0, + "pressure": 1010, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "20", + "temp": 8, + "ww": "3", + "precipChance": "40", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 35, + "windPeakSpeedKm": 55, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "21", + "temp": 8, + "ww": "6", + "precipChance": "40", + "precipQuantity": 0.11, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "22", + "temp": 8, + "ww": "18", + "precipChance": "40", + "precipQuantity": 0.21, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "23", + "temp": 8, + "ww": "15", + "precipChance": "40", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "00", + "temp": 8, + "ww": "15", + "precipChance": "20", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n", + "dateShow": "28/12", + "dateShowLocalized": { + "nl": "Don.", + "fr": "Jeu.", + "en": "Thu.", + "de": "Don." + } + }, + { + "hour": "01", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "02", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "03", + "temp": 8, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 25, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "04", + "temp": 8, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1014, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "05", + "temp": 8, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "06", + "temp": 7, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1014, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "07", + "temp": 7, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1014, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "08", + "temp": 7, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1014, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "09", + "temp": 7, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0.01, + "pressure": 1014, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "d" + }, + { + "hour": "10", + "temp": 8, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0.01, + "pressure": 1014, + "windSpeedKm": 30, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "d" + }, + { + "hour": "11", + "temp": 8, + "ww": "14", + "precipChance": "0", + "precipQuantity": 0.02, + "pressure": 1014, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "d" + }, + { + "hour": "12", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1014, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "13", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 35, + "windPeakSpeedKm": 60, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "14", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0.01, + "pressure": 1013, + "windSpeedKm": 30, + "windPeakSpeedKm": 60, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "15", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 25, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "16", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "d" + }, + { + "hour": "17", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "18", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + } + ], + "warning": [] + }, + "module": [ + { + "type": "svg", + "data": { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=pollen&l=nl&k=782832cc606de3bad9b7f2002de4b4b1", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=pollen&l=fr&k=782832cc606de3bad9b7f2002de4b4b1", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=pollen&l=en&k=782832cc606de3bad9b7f2002de4b4b1", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=pollen&l=de&k=782832cc606de3bad9b7f2002de4b4b1" + }, + "ratio": 3.0458333333333334 + } + }, + { + "type": "uv", + "data": { + "levelValue": 0.7, + "level": { + "nl": "Laag", + "fr": "Faible", + "en": "Low", + "de": "Niedrig" + }, + "title": { + "nl": "Uv-index", + "fr": "Indice UV", + "en": "UV Index", + "de": "UV Index" + } + } + }, + { + "type": "observation", + "data": { + "count": 690, + "title": { + "nl": "Waarnemingen vandaag", + "fr": "Observations d'aujourd'hui", + "en": "Today's Observations", + "de": "Beobachtungen heute" + } + } + }, + { + "type": "svg", + "data": { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=nl&k=782832cc606de3bad9b7f2002de4b4b1", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=fr&k=782832cc606de3bad9b7f2002de4b4b1", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=en&k=782832cc606de3bad9b7f2002de4b4b1", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=de&k=782832cc606de3bad9b7f2002de4b4b1" + }, + "ratio": 1.6587926509186353 + } + } + ], + "animation": { + "localisationLayer": "https://app.meteo.be/services/appv4/?s=getLocalizationLayerBE&ins=92094&f=2&k=2c886c51e74b671c8fc3865f4a0e9318", + "localisationLayerRatioX": 0.6667, + "localisationLayerRatioY": 0.523, + "speed": 0.3, + "type": "10min", + "unit": { + "fr": "mm/10min", + "nl": "mm/10min", + "en": "mm/10min", + "de": "mm/10min" + }, + "country": "BE", + "sequence": [ + { + "time": "2023-12-26T17:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312261610&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-26T17:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312261620&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-26T17:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312261630&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-26T17:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312261640&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-26T17:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312261650&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", + "value": 0.1, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-26T17:50:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312261700&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", + "value": 0.01, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-26T18:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312261710&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", + "value": 0.12, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-26T18:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312261720&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", + "value": 1.2, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-26T18:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312261730&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", + "value": 2, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-26T18:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312261740&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-26T18:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312261750&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + } + ], + "threshold": [], + "sequenceHint": { + "nl": "Geen regen voorzien op korte termijn", + "fr": "Pas de pluie prévue prochainement", + "en": "No rain forecasted shortly", + "de": "Kein Regen erwartet in naher Zukunft" + } + }, + "todayObsCount": 690 +} diff --git a/tests/components/irm_kmi/fixtures/forecast_nl.json b/tests/components/irm_kmi/fixtures/forecast_nl.json new file mode 100644 index 000000000000..452ba581cc0e --- /dev/null +++ b/tests/components/irm_kmi/fixtures/forecast_nl.json @@ -0,0 +1,1355 @@ +{ + "cityName": "Lelystad", + "country": "NL", + "obs": { + "ww": 15, + "municipality_code": "0995", + "temp": 11, + "windSpeedKm": 40, + "timestamp": "2023-12-28T14:30:00+00:00", + "windDirection": 45, + "municipality": "Lelystad", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + }, + "dayNight": "d" + }, + "for": { + "daily": [ + { + "dayName": { + "nl": "Vandaag", + "fr": "Aujourd'hui", + "de": "Heute", + "en": "Today" + }, + "timestamp": "2023-12-28T12:00:00+00:00", + "text": { + "nl": "Waarschuwingen \nVanavond zijn er in het noordwesten zware windstoten mogelijk van 75-90 km/uur (code geel).\n\nVanochtend is het half bewolkt met in het noorden kans op een bui. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, af en toe even stormachtig, windkracht 8. Aan de kust komen windstoten voor van ongeveer 80 km/uur.\nVanmiddag is het half tot zwaar bewolkt met kans op een bui, vooral in het noorden en westen. De middagtemperatuur ligt rond 11°C. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, vooral later ook af en toe stormachtig, windkracht 8. Aan de kust zijn er windstoten tot ongeveer 80 km/uur.\nVanavond zijn er buien, alleen in het zuidoosten is het overwegend droog. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust hard tot stormachtig, windkracht 7 tot 8. Vooral in het noordwesten zijn windstoten mogelijk van 75-90 km/uur.\n\nKomende nacht komen er enkele buien voor. Met een minimumtemperatuur van ongeveer 8°C is het zacht. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met zware windstoten tot ongeveer 80 km/uur.\n\nMorgenochtend is het half tot zwaar bewolkt en zijn er enkele buien. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig hard, windkracht 6-7, met vooral in het noordwesten mogelijk zware windstoten tot ongeveer 80 km/uur.\nMorgenmiddag is er af en toe ruimte voor de zon en blijft het op de meeste plaatsen droog, alleen in het zuidoosten kan een enkele bui vallen. Met middagtemperaturen van ongeveer 10°C blijft het zacht. De wind uit het zuidwesten is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met in het Waddengebied zware windstoten tot ongeveer 80 km/uur.\nMorgenavond is het half tot zwaar bewolkt met een enkele bui. De wind komt uit het zuidwesten en is meest matig, aan de kust krachtig tot hard, windkracht 6-7, boven de Wadden eerst stormachtig, windkracht 8. \n(Bron: KNMI, 2023-12-28T06:56:00+01:00)\n", + "en": "Waarschuwingen \nVanavond zijn er in het noordwesten zware windstoten mogelijk van 75-90 km/uur (code geel).\n\nVanochtend is het half bewolkt met in het noorden kans op een bui. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, af en toe even stormachtig, windkracht 8. Aan de kust komen windstoten voor van ongeveer 80 km/uur.\nVanmiddag is het half tot zwaar bewolkt met kans op een bui, vooral in het noorden en westen. De middagtemperatuur ligt rond 11°C. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, vooral later ook af en toe stormachtig, windkracht 8. Aan de kust zijn er windstoten tot ongeveer 80 km/uur.\nVanavond zijn er buien, alleen in het zuidoosten is het overwegend droog. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust hard tot stormachtig, windkracht 7 tot 8. Vooral in het noordwesten zijn windstoten mogelijk van 75-90 km/uur.\n\nKomende nacht komen er enkele buien voor. Met een minimumtemperatuur van ongeveer 8°C is het zacht. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met zware windstoten tot ongeveer 80 km/uur.\n\nMorgenochtend is het half tot zwaar bewolkt en zijn er enkele buien. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig hard, windkracht 6-7, met vooral in het noordwesten mogelijk zware windstoten tot ongeveer 80 km/uur.\nMorgenmiddag is er af en toe ruimte voor de zon en blijft het op de meeste plaatsen droog, alleen in het zuidoosten kan een enkele bui vallen. Met middagtemperaturen van ongeveer 10°C blijft het zacht. De wind uit het zuidwesten is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met in het Waddengebied zware windstoten tot ongeveer 80 km/uur.\nMorgenavond is het half tot zwaar bewolkt met een enkele bui. De wind komt uit het zuidwesten en is meest matig, aan de kust krachtig tot hard, windkracht 6-7, boven de Wadden eerst stormachtig, windkracht 8. \n(Bron: KNMI, 2023-12-28T06:56:00+01:00)\n", + "fr": "Waarschuwingen \nVanavond zijn er in het noordwesten zware windstoten mogelijk van 75-90 km/uur (code geel).\n\nVanochtend is het half bewolkt met in het noorden kans op een bui. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, af en toe even stormachtig, windkracht 8. Aan de kust komen windstoten voor van ongeveer 80 km/uur.\nVanmiddag is het half tot zwaar bewolkt met kans op een bui, vooral in het noorden en westen. De middagtemperatuur ligt rond 11°C. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, vooral later ook af en toe stormachtig, windkracht 8. Aan de kust zijn er windstoten tot ongeveer 80 km/uur.\nVanavond zijn er buien, alleen in het zuidoosten is het overwegend droog. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust hard tot stormachtig, windkracht 7 tot 8. Vooral in het noordwesten zijn windstoten mogelijk van 75-90 km/uur.\n\nKomende nacht komen er enkele buien voor. Met een minimumtemperatuur van ongeveer 8°C is het zacht. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met zware windstoten tot ongeveer 80 km/uur.\n\nMorgenochtend is het half tot zwaar bewolkt en zijn er enkele buien. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig hard, windkracht 6-7, met vooral in het noordwesten mogelijk zware windstoten tot ongeveer 80 km/uur.\nMorgenmiddag is er af en toe ruimte voor de zon en blijft het op de meeste plaatsen droog, alleen in het zuidoosten kan een enkele bui vallen. Met middagtemperaturen van ongeveer 10°C blijft het zacht. De wind uit het zuidwesten is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met in het Waddengebied zware windstoten tot ongeveer 80 km/uur.\nMorgenavond is het half tot zwaar bewolkt met een enkele bui. De wind komt uit het zuidwesten en is meest matig, aan de kust krachtig tot hard, windkracht 6-7, boven de Wadden eerst stormachtig, windkracht 8. \n(Bron: KNMI, 2023-12-28T06:56:00+01:00)\n", + "de": "Waarschuwingen \nVanavond zijn er in het noordwesten zware windstoten mogelijk van 75-90 km/uur (code geel).\n\nVanochtend is het half bewolkt met in het noorden kans op een bui. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, af en toe even stormachtig, windkracht 8. Aan de kust komen windstoten voor van ongeveer 80 km/uur.\nVanmiddag is het half tot zwaar bewolkt met kans op een bui, vooral in het noorden en westen. De middagtemperatuur ligt rond 11°C. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, vooral later ook af en toe stormachtig, windkracht 8. Aan de kust zijn er windstoten tot ongeveer 80 km/uur.\nVanavond zijn er buien, alleen in het zuidoosten is het overwegend droog. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust hard tot stormachtig, windkracht 7 tot 8. Vooral in het noordwesten zijn windstoten mogelijk van 75-90 km/uur.\n\nKomende nacht komen er enkele buien voor. Met een minimumtemperatuur van ongeveer 8°C is het zacht. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met zware windstoten tot ongeveer 80 km/uur.\n\nMorgenochtend is het half tot zwaar bewolkt en zijn er enkele buien. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig hard, windkracht 6-7, met vooral in het noordwesten mogelijk zware windstoten tot ongeveer 80 km/uur.\nMorgenmiddag is er af en toe ruimte voor de zon en blijft het op de meeste plaatsen droog, alleen in het zuidoosten kan een enkele bui vallen. Met middagtemperaturen van ongeveer 10°C blijft het zacht. De wind uit het zuidwesten is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met in het Waddengebied zware windstoten tot ongeveer 80 km/uur.\nMorgenavond is het half tot zwaar bewolkt met een enkele bui. De wind komt uit het zuidwesten en is meest matig, aan de kust krachtig tot hard, windkracht 6-7, boven de Wadden eerst stormachtig, windkracht 8. \n(Bron: KNMI, 2023-12-28T06:56:00+01:00)\n" + }, + "dayNight": "d", + "tempMin": null, + "tempMax": 11, + "ww1": 4, + "ww2": null, + "wwevol": null, + "ff1": 5, + "ff2": null, + "ffevol": null, + "windSpeedKm": 32, + "dd": 45, + "ddText": { + "nl": "ZW", + "fr": "SO", + "de": "SW", + "en": "SW" + }, + "wind": { + "speed": 32, + "peakSpeed": 33, + "dir": 45, + "dirText": { + "nl": "ZW", + "fr": "SO", + "de": "SW", + "en": "SW" + } + }, + "precipChance": null, + "precipQuantity": 0.1, + "uvIndex": 1, + "sunRiseUtc": 28063, + "sunSetUtc": 56046, + "sunRise": 31663, + "sunSet": 59646 + }, + { + "dayName": { + "nl": "Vannacht", + "fr": "Cette nuit", + "de": "Heute abend", + "en": "Tonight" + }, + "timestamp": "2023-12-29T00:00:00+00:00", + "dayNight": "n", + "tempMin": 9, + "tempMax": null, + "ww1": 15, + "ww2": null, + "wwevol": null, + "ff1": 5, + "ff2": null, + "ffevol": null, + "windSpeedKm": 31, + "dd": 45, + "ddText": { + "nl": "ZW", + "fr": "SO", + "de": "SW", + "en": "SW" + }, + "wind": { + "speed": 31, + "peakSpeed": 32, + "dir": 45, + "dirText": { + "nl": "ZW", + "fr": "SO", + "de": "SW", + "en": "SW" + } + }, + "precipChance": null, + "precipQuantity": 3, + "uvIndex": null, + "sunRiseUtc": null, + "sunSetUtc": null, + "sunRise": null, + "sunSet": null + }, + { + "dayName": { + "nl": "Morgen", + "fr": "Demain", + "de": "Morgen", + "en": "Tomorrow" + }, + "timestamp": "2023-12-29T12:00:00+00:00", + "dayNight": "d", + "tempMin": null, + "tempMax": 10, + "ww1": 3, + "ww2": null, + "wwevol": null, + "ff1": 5, + "ff2": null, + "ffevol": null, + "windSpeedKm": 26, + "dd": 68, + "ddText": { + "nl": "WZW", + "fr": "OSO", + "de": "WSW", + "en": "WSW" + }, + "wind": { + "speed": 26, + "peakSpeed": 28, + "dir": 68, + "dirText": { + "nl": "WZW", + "fr": "OSO", + "de": "WSW", + "en": "WSW" + } + }, + "precipChance": null, + "precipQuantity": 3.8, + "uvIndex": 1, + "sunRiseUtc": 28068, + "sunSetUtc": 56100, + "sunRise": 31668, + "sunSet": 59700 + }, + { + "dayName": { + "nl": "Zaterdag", + "fr": "Samedi", + "de": "Samstag", + "en": "Saturday" + }, + "timestamp": "2023-12-30T12:00:00+00:00", + "dayNight": "d", + "tempMin": 5, + "tempMax": 10, + "ww1": 16, + "ww2": null, + "wwevol": null, + "ff1": 4, + "ff2": null, + "ffevol": null, + "windSpeedKm": 22, + "dd": 45, + "ddText": { + "nl": "ZW", + "fr": "SO", + "de": "SW", + "en": "SW" + }, + "wind": { + "speed": 22, + "peakSpeed": 25, + "dir": 45, + "dirText": { + "nl": "ZW", + "fr": "SO", + "de": "SW", + "en": "SW" + } + }, + "precipChance": null, + "precipQuantity": 1.7, + "uvIndex": 1, + "sunRiseUtc": 28069, + "sunSetUtc": 56157, + "sunRise": 31669, + "sunSet": 59757 + }, + { + "dayName": { + "nl": "Zondag", + "fr": "Dimanche", + "de": "Sonntag", + "en": "Sunday" + }, + "timestamp": "2023-12-31T12:00:00+00:00", + "dayNight": "d", + "tempMin": 7, + "tempMax": 9, + "ww1": 19, + "ww2": null, + "wwevol": null, + "ff1": 5, + "ff2": null, + "ffevol": null, + "windSpeedKm": 30, + "dd": 23, + "ddText": { + "nl": "ZZW", + "fr": "SSO", + "de": "SSW", + "en": "SSW" + }, + "wind": { + "speed": 30, + "peakSpeed": 31, + "dir": 23, + "dirText": { + "nl": "ZZW", + "fr": "SSO", + "de": "SSW", + "en": "SSW" + } + }, + "precipChance": null, + "precipQuantity": 4.2, + "uvIndex": 1, + "sunRiseUtc": 28067, + "sunSetUtc": 56216, + "sunRise": 31667, + "sunSet": 59816 + }, + { + "dayName": { + "nl": "Maandag", + "fr": "Lundi", + "de": "Montag", + "en": "Monday" + }, + "timestamp": "2024-01-01T12:00:00+00:00", + "dayNight": "d", + "tempMin": 5, + "tempMax": 7, + "ww1": 16, + "ww2": null, + "wwevol": null, + "ff1": 4, + "ff2": null, + "ffevol": null, + "windSpeedKm": 23, + "dd": 45, + "ddText": { + "nl": "ZW", + "fr": "SO", + "de": "SW", + "en": "SW" + }, + "wind": { + "speed": 23, + "peakSpeed": 28, + "dir": 45, + "dirText": { + "nl": "ZW", + "fr": "SO", + "de": "SW", + "en": "SW" + } + }, + "precipChance": null, + "precipQuantity": 2.2, + "uvIndex": 1, + "sunRiseUtc": 28062, + "sunSetUtc": 56279, + "sunRise": 31662, + "sunSet": 59879 + }, + { + "dayName": { + "nl": "Dinsdag", + "fr": "Mardi", + "de": "Dienstag", + "en": "Tuesday" + }, + "timestamp": "2024-01-02T12:00:00+00:00", + "dayNight": "d", + "tempMin": 3, + "tempMax": 6, + "ww1": 16, + "ww2": null, + "wwevol": null, + "ff1": 3, + "ff2": null, + "ffevol": null, + "windSpeedKm": 15, + "dd": 45, + "ddText": { + "nl": "ZW", + "fr": "SO", + "de": "SW", + "en": "SW" + }, + "wind": { + "speed": 15, + "peakSpeed": 16, + "dir": 45, + "dirText": { + "nl": "ZW", + "fr": "SO", + "de": "SW", + "en": "SW" + } + }, + "precipChance": null, + "precipQuantity": 1.4, + "uvIndex": 1, + "sunRiseUtc": 28052, + "sunSetUtc": 56344, + "sunRise": 31652, + "sunSet": 59944 + }, + { + "dayName": { + "nl": "Woensdag", + "fr": "Mercredi", + "de": "Mittwoch", + "en": "Wednesday" + }, + "timestamp": "2024-01-03T12:00:00+00:00", + "dayNight": "d", + "tempMin": 3, + "tempMax": 6, + "ww1": 16, + "ww2": null, + "wwevol": null, + "ff1": 3, + "ff2": null, + "ffevol": null, + "windSpeedKm": 13, + "dd": 23, + "ddText": { + "nl": "ZZW", + "fr": "SSO", + "de": "SSW", + "en": "SSW" + }, + "wind": { + "speed": 13, + "peakSpeed": 14, + "dir": 23, + "dirText": { + "nl": "ZZW", + "fr": "SSO", + "de": "SSW", + "en": "SSW" + } + }, + "precipChance": null, + "precipQuantity": 1, + "uvIndex": 1, + "sunRiseUtc": 28040, + "sunSetUtc": 56412, + "sunRise": 31640, + "sunSet": 60012 + } + ], + "showWarningTab": false, + "graph": { + "svg": [ + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=tx&l=nl&k=353efbb53695c7207f520b00303e716a", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=tx&l=fr&k=353efbb53695c7207f520b00303e716a", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=tx&l=en&k=353efbb53695c7207f520b00303e716a", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=tx&l=de&k=353efbb53695c7207f520b00303e716a" + }, + "ratio": 1.3638709677419354 + }, + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=tn&l=nl&k=353efbb53695c7207f520b00303e716a", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=tn&l=fr&k=353efbb53695c7207f520b00303e716a", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=tn&l=en&k=353efbb53695c7207f520b00303e716a", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=tn&l=de&k=353efbb53695c7207f520b00303e716a" + }, + "ratio": 1.3638709677419354 + }, + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=rr&l=nl&k=353efbb53695c7207f520b00303e716a", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=rr&l=fr&k=353efbb53695c7207f520b00303e716a", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=rr&l=en&k=353efbb53695c7207f520b00303e716a", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=rr&l=de&k=353efbb53695c7207f520b00303e716a" + }, + "ratio": 1.3638709677419354 + } + ] + }, + "hourly": [ + { + "hourUtc": "14", + "hour": "15", + "temp": 10, + "windSpeedKm": 33, + "dayNight": "d", + "ww": "15", + "pressure": "1008", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "15", + "hour": "16", + "temp": 10, + "windSpeedKm": 32, + "dayNight": "d", + "ww": "15", + "pressure": "1008", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "16", + "hour": "17", + "temp": 10, + "windSpeedKm": 32, + "dayNight": "n", + "ww": "15", + "pressure": "1007", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "17", + "hour": "18", + "temp": 10, + "windSpeedKm": 35, + "dayNight": "n", + "ww": "15", + "pressure": "1007", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "18", + "hour": "19", + "temp": 10, + "windSpeedKm": 35, + "dayNight": "n", + "ww": "15", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "19", + "hour": "20", + "temp": 10, + "windSpeedKm": 35, + "dayNight": "n", + "ww": "15", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "20", + "hour": "21", + "temp": 10, + "windSpeedKm": 35, + "dayNight": "n", + "ww": "15", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "21", + "hour": "22", + "temp": 10, + "windSpeedKm": 35, + "dayNight": "n", + "ww": "16", + "pressure": "1006", + "precipQuantity": 0.7, + "precipChance": "70", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "22", + "hour": "23", + "temp": 10, + "windSpeedKm": 37, + "dayNight": "n", + "ww": "16", + "pressure": "1006", + "precipQuantity": 0.1, + "precipChance": "10", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "23", + "hour": "00", + "temp": 10, + "dateShowLocalized": { + "fr": "Ven.", + "en": "Fri.", + "nl": "Vri.", + "de": "Fre." + }, + "windSpeedKm": 35, + "dayNight": "n", + "ww": "15", + "pressure": "1006", + "precipQuantity": 0, + "dateShow": "29/12", + "precipChance": "20", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "00", + "hour": "01", + "temp": 10, + "windSpeedKm": 31, + "dayNight": "n", + "ww": "16", + "pressure": "1005", + "precipQuantity": 1.9, + "precipChance": "80", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "01", + "hour": "02", + "temp": 10, + "windSpeedKm": 38, + "dayNight": "n", + "ww": "16", + "pressure": "1005", + "precipQuantity": 0.6, + "precipChance": "70", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "02", + "hour": "03", + "temp": 10, + "windSpeedKm": 35, + "dayNight": "n", + "ww": "3", + "pressure": "1005", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "03", + "hour": "04", + "temp": 10, + "windSpeedKm": 34, + "dayNight": "n", + "ww": "15", + "pressure": "1005", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "04", + "hour": "05", + "temp": 9, + "windSpeedKm": 35, + "dayNight": "n", + "ww": "3", + "pressure": "1005", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "05", + "hour": "06", + "temp": 9, + "windSpeedKm": 34, + "dayNight": "n", + "ww": "15", + "pressure": "1005", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "06", + "hour": "07", + "temp": 9, + "windSpeedKm": 32, + "dayNight": "n", + "ww": "3", + "pressure": "1005", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "07", + "hour": "08", + "temp": 9, + "windSpeedKm": 31, + "dayNight": "n", + "ww": "3", + "pressure": "1005", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "08", + "hour": "09", + "temp": 9, + "windSpeedKm": 31, + "dayNight": "d", + "ww": "3", + "pressure": "1005", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "09", + "hour": "10", + "temp": 9, + "windSpeedKm": 32, + "dayNight": "d", + "ww": "15", + "pressure": "1005", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "10", + "hour": "11", + "temp": 10, + "windSpeedKm": 32, + "dayNight": "d", + "ww": "3", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "11", + "hour": "12", + "temp": 10, + "windSpeedKm": 34, + "dayNight": "d", + "ww": "3", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "12", + "hour": "13", + "temp": 10, + "windSpeedKm": 33, + "dayNight": "d", + "ww": "3", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "13", + "hour": "14", + "temp": 10, + "windSpeedKm": 31, + "dayNight": "d", + "ww": "3", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "14", + "hour": "15", + "temp": 10, + "windSpeedKm": 28, + "dayNight": "d", + "ww": "0", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "15", + "hour": "16", + "temp": 9, + "windSpeedKm": 24, + "dayNight": "d", + "ww": "0", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "16", + "hour": "17", + "temp": 8, + "windSpeedKm": 20, + "dayNight": "n", + "ww": "0", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "17", + "hour": "18", + "temp": 8, + "windSpeedKm": 18, + "dayNight": "n", + "ww": "3", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "18", + "hour": "19", + "temp": 8, + "windSpeedKm": 15, + "dayNight": "n", + "ww": "15", + "pressure": "1005", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "23", + "windDirectionText": { + "fr": "SSO", + "en": "SSW", + "nl": "ZZW", + "de": "SSW" + } + }, + { + "hourUtc": "19", + "hour": "20", + "temp": 8, + "windSpeedKm": 22, + "dayNight": "n", + "ww": "16", + "pressure": "1005", + "precipQuantity": 5.7, + "precipChance": "100", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "20", + "hour": "21", + "temp": 7, + "windSpeedKm": 26, + "dayNight": "n", + "ww": "6", + "pressure": "1006", + "precipQuantity": 3.8, + "precipChance": "100", + "windDirection": "90", + "windDirectionText": { + "fr": "O", + "en": "W", + "nl": "W", + "de": "W" + } + }, + { + "hourUtc": "21", + "hour": "22", + "temp": 8, + "windSpeedKm": 24, + "dayNight": "n", + "ww": "3", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "22", + "hour": "23", + "temp": 7, + "windSpeedKm": 22, + "dayNight": "n", + "ww": "15", + "pressure": "1007", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "23", + "hour": "00", + "temp": 8, + "dateShowLocalized": { + "fr": "Sam.", + "en": "Sat.", + "nl": "Zat.", + "de": "Sam." + }, + "windSpeedKm": 26, + "dayNight": "n", + "ww": "3", + "pressure": "1008", + "precipQuantity": 0, + "dateShow": "30/12", + "precipChance": "0", + "windDirection": "90", + "windDirectionText": { + "fr": "O", + "en": "W", + "nl": "W", + "de": "W" + } + }, + { + "hourUtc": "00", + "hour": "01", + "temp": 7, + "windSpeedKm": 26, + "dayNight": "n", + "ww": "0", + "pressure": "1007", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "90", + "windDirectionText": { + "fr": "O", + "en": "W", + "nl": "W", + "de": "W" + } + }, + { + "hourUtc": "01", + "hour": "02", + "temp": 7, + "windSpeedKm": 24, + "dayNight": "n", + "ww": "0", + "pressure": "1008", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "90", + "windDirectionText": { + "fr": "O", + "en": "W", + "nl": "W", + "de": "W" + } + }, + { + "hourUtc": "02", + "hour": "03", + "temp": 7, + "windSpeedKm": 24, + "dayNight": "n", + "ww": "3", + "pressure": "1008", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "90", + "windDirectionText": { + "fr": "O", + "en": "W", + "nl": "W", + "de": "W" + } + }, + { + "hourUtc": "03", + "hour": "04", + "temp": 7, + "windSpeedKm": 23, + "dayNight": "n", + "ww": "0", + "pressure": "1009", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "04", + "hour": "05", + "temp": 6, + "windSpeedKm": 23, + "dayNight": "n", + "ww": "0", + "pressure": "1009", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "05", + "hour": "06", + "temp": 6, + "windSpeedKm": 21, + "dayNight": "n", + "ww": "3", + "pressure": "1009", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "06", + "hour": "07", + "temp": 6, + "windSpeedKm": 20, + "dayNight": "n", + "ww": "3", + "pressure": "1010", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "07", + "hour": "08", + "temp": 6, + "windSpeedKm": 17, + "dayNight": "n", + "ww": "3", + "pressure": "1011", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "08", + "hour": "09", + "temp": 6, + "windSpeedKm": 13, + "dayNight": "d", + "ww": "0", + "pressure": "1011", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "09", + "hour": "10", + "temp": 5, + "windSpeedKm": 12, + "dayNight": "d", + "ww": "3", + "pressure": "1012", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + } + ], + "warning": [] + }, + "module": [ + { + "type": "uv", + "data": { + "levelValue": 1, + "level": { + "nl": "Laag", + "fr": "Faible", + "en": "Low", + "de": "Niedrig" + }, + "title": { + "nl": "Uv-index", + "fr": "Indice UV", + "en": "UV Index", + "de": "UV Index" + } + } + }, + { + "type": "observation", + "data": { + "count": 480, + "title": { + "nl": "Waarnemingen vandaag", + "fr": "Observations d'aujourd'hui", + "en": "Today's Observations", + "de": "Beobachtungen heute" + } + } + }, + { + "type": "svg", + "data": { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem_KNMI&l=nl&k=353efbb53695c7207f520b00303e716a", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem_KNMI&l=fr&k=353efbb53695c7207f520b00303e716a", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem_KNMI&l=en&k=353efbb53695c7207f520b00303e716a", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem_KNMI&l=de&k=353efbb53695c7207f520b00303e716a" + }, + "ratio": 1.6587926509186353 + } + } + ], + "animation": { + "localisationLayer": "https://app.meteo.be/services/appv4/?s=getLocalizationLayerNL&ins=200995&f=2&k=9145c16494963cfccf2854556ee8bf52", + "localisationLayerRatioX": 0.5716, + "localisationLayerRatioY": 0.3722, + "speed": 0.3, + "type": "5min", + "unit": { + "fr": "mm/h", + "nl": "mm/h", + "en": "mm/h", + "de": "mm/Std" + }, + "country": "NL", + "sequence": [ + { + "time": "2023-12-28T13:50:00+00:00", + "uri": "https://cdn.knmi.nl/knmi/map/page/weer/actueel-weer/neerslagradar/weerapp/RAD_NL25_PCP_CM_202312281350_640.png", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-28T13:55:00+00:00", + "uri": "https://cdn.knmi.nl/knmi/map/page/weer/actueel-weer/neerslagradar/weerapp/RAD_NL25_PCP_CM_202312281355_640.png", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-28T14:00:00+00:00", + "uri": "https://cdn.knmi.nl/knmi/map/page/weer/actueel-weer/neerslagradar/weerapp/RAD_NL25_PCP_CM_202312281400_640.png", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-28T14:05:00+00:00", + "uri": "https://cdn.knmi.nl/knmi/map/page/weer/actueel-weer/neerslagradar/weerapp/RAD_NL25_PCP_CM_202312281405_640.png", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-28T14:10:00+00:00", + "uri": "https://cdn.knmi.nl/knmi/map/page/weer/actueel-weer/neerslagradar/weerapp/RAD_NL25_PCP_CM_202312281410_640.png", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-28T14:15:00+00:00", + "uri": "https://cdn.knmi.nl/knmi/map/page/weer/actueel-weer/neerslagradar/weerapp/RAD_NL25_PCP_CM_202312281415_640.png", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-28T14:20:00+00:00", + "uri": "https://cdn.knmi.nl/knmi/map/page/weer/actueel-weer/neerslagradar/weerapp/RAD_NL25_PCP_CM_202312281420_640.png", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-28T14:25:00+00:00", + "uri": "https://cdn.knmi.nl/knmi/map/page/weer/actueel-weer/neerslagradar/weerapp/RAD_NL25_PCP_CM_202312281425_640.png", + "value": 0.15, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + } + ], + "threshold": [], + "sequenceHint": { + "nl": "Geen regen voorzien op korte termijn", + "fr": "Pas de pluie prévue prochainement", + "en": "No rain forecasted shortly", + "de": "Kein Regen erwartet in naher Zukunft" + } + }, + "todayObsCount": 480 +} diff --git a/tests/components/irm_kmi/fixtures/forecast_out_of_benelux.json b/tests/components/irm_kmi/fixtures/forecast_out_of_benelux.json new file mode 100644 index 000000000000..a2b2a805e2cd --- /dev/null +++ b/tests/components/irm_kmi/fixtures/forecast_out_of_benelux.json @@ -0,0 +1,1625 @@ +{ + "cityName": "Hors de Belgique (Bxl)", + "country": "BE", + "obs": { + "temp": 9, + "timestamp": "2023-12-27T11:20:00+01:00", + "ww": 15, + "dayNight": "d" + }, + "for": { + "daily": [ + { + "dayName": { + "fr": "Mercredi", + "nl": "Woensdag", + "en": "Wednesday", + "de": "Mittwoch" + }, + "period": "1", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Deze ochtend start de dag betrokken met lichte regen op de meeste plaatsen. In de voormiddag verlaat de zwakke regenzone ons land via Nederland. In de namiddag blijft het droog en wordt het vrij zonnig met soms wat meer hoge sluierwolken. De maxima liggen tussen 5 en 8 graden in het zuiden van het land en rond 9 of 10 graden in het centrum en aan zee. De matige zuidenwind ruimt naar zuidzuidwest en wordt vrij krachtig tot lokaal krachtig aan zee. Vooral in de kuststreek en op het Ardense reliëf zijn er windstoten mogelijk rond 50 km/h.", + "fr": "Ce matin, la journée débutera sous les nuages et de faibles pluies en de nombreux endroits. En matinée, cette zone de précipitations affaiblies quittera notre pays pour les Pays-Bas. L'après-midi, le temps restera sec et assez ensoleillé même si le soleil sera parfois masqué par des champs de nuages élevés. Les maxima seront compris entre 5 et 8 degrés dans le sud et proches de 9 ou 10 degrés en dans le centre et à la mer. Le vent modéré de sud virera au sud-sud-ouest et deviendra assez fort, à parfois fort au littoral. Des rafales de 50 km/h pourront se produire, essentiellement à la côte et sur les hauteurs de l'Ardenne." + }, + "dawnRiseSeconds": "31440", + "dawnSetSeconds": "60180", + "tempMin": null, + "tempMax": 10, + "ww1": 14, + "ww2": 3, + "wwevol": 0, + "ff1": 4, + "ff2": null, + "ffevol": null, + "dd": 22, + "ddText": { + "fr": "SSO", + "nl": "ZZW", + "en": "SSW", + "de": "SSW" + }, + "wind": { + "speed": 20, + "peakSpeed": null, + "dir": 22, + "dirText": { + "fr": "SSO", + "nl": "ZZW", + "en": "SSW", + "de": "SSW" + } + }, + "precipChance": 0, + "precipQuantity": "0" + }, + { + "dayName": { + "fr": "Cette nuit", + "nl": "Vannacht", + "en": "Tonight", + "de": "heute abend" + }, + "period": "2", + "day_night": "0", + "dayNight": "n", + "text": { + "nl": "Vanavond en vannacht trekt een volgende (zwakke) storing door het land van west naar oost met wat regen of enkele buien. Aan de achterzijde van deze storing klaart het uit. Tegen het einde van de nacht verlaat de regezone stilaan ons land via het zuidoosten. De minima liggen tussen 4 en 9 graden. Er staat een matige tot vrij krachtige zuidwestenwind met rukwinden tot 60 km/h.", + "fr": "Ce soir et cette nuit, une (faible) perturbation traversera le pays d'ouest en est avec un peu de pluie ou quelques averses. A l'arrière, le ciel se dégagera. A l'aube, le zone de précipitations quittera progressivement le pays par le sud-est. Les minima varieront de 4 à 9 degrés, sous un vent modéré à assez fort de sud-ouest. Les rafales pourront atteindre des valeurs de 60 km/h." + }, + "dawnRiseSeconds": "31440", + "dawnSetSeconds": "60180", + "tempMin": 9, + "tempMax": null, + "ww1": 6, + "ww2": 3, + "wwevol": 0, + "ff1": 5, + "ff2": 4, + "ffevol": 0, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 29, + "peakSpeed": "55", + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 100, + "precipQuantity": "1" + }, + { + "dayName": { + "fr": "Jeudi", + "nl": "Donderdag", + "en": "Thursday", + "de": "Donnerstag" + }, + "period": "3", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Morgen wisselen opklaringen en wolken elkaar af, waaruit plaatselijk enkele buien kunnen vallen. Aan het begin van de dag hangt er in de Ardennen veel lage bewolking. Het is vrij winderig en zeer zacht met maxima van 7 graden in de Hoge Ardennen tot 11 graden over het westen van het land. De zuidwestenwind is matig tot vrij krachtig met windstoten tot 65 km/h.", + "fr": "Demain, nuages et éclaircies se partageront le ciel avec quelques averses isolées. En début de journée, les nuages bas pourraient encore s'accrocher sur l'Ardenne. Le temps sera assez venteux et très doux avec des maxima de 7 degrés en Haute Ardenne à 11 degrés sur l'ouest du pays. Le vent de sud-ouest sera modéré à assez fort, avec des rafales jusqu'à 65 km/h." + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60180", + "tempMin": 9, + "tempMax": 11, + "ww1": 1, + "ww2": 3, + "wwevol": 0, + "ff1": 5, + "ff2": 4, + "ffevol": 1, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 29, + "peakSpeed": "60", + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 0, + "precipQuantity": "0" + }, + { + "dayName": { + "fr": "Vendredi", + "nl": "Vrijdag", + "en": "Friday", + "de": "Freitag" + }, + "period": "5", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Vrijdag is het wisselvallig en winderig. Bij momenten vallen er intense regenbuien. De maxima klimmen naar waarden tussen 7 en 11 graden bij een vrij krachtige zuidwestenwind. Er zijn rukwinden mogelijk tot 70 km/h.", + "fr": "Vendredi, le temps sera variable, doux et venteux. De nouvelles pluies parfois abondantes et sous forme d'averses traverseront notre pays. Les maxima varieront entre 7 et 11 degrés avec un vent assez fort de sud-ouest. Les rafales pourront atteindre 70 km/h." + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60240", + "tempMin": 9, + "tempMax": 10, + "ww1": 6, + "ww2": 3, + "wwevol": 0, + "ff1": 5, + "ff2": 4, + "ffevol": 1, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 29, + "peakSpeed": "55", + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 100, + "precipQuantity": "1" + }, + { + "dayName": { + "fr": "Samedi", + "nl": "Zaterdag", + "en": "Saturday", + "de": "Samstag" + }, + "period": "7", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Zaterdagvoormiddag is het vaak droog met tijdelijk opklaringen. In de loop van de dag neemt de bewolking toe, gevolgd door regen vanuit het westen. De maxima schommelen tussen 5 en 10 graden. De wind wordt vrij krachtig en krachtig aan zee uit zuidwest.", + "fr": "Samedi matin, le temps sera souvent sec avec temporairement des éclaircies. Dans le courant de la journée, la nébulosité augmentera, et sera suivie de pluies depuis l'ouest. Les maxima varieront entre 5 et 10 degrés. Le vent de sud-ouest sera assez fort, à fort le long du littoral." + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60300", + "tempMin": 4, + "tempMax": 8, + "ww1": 1, + "ww2": 15, + "wwevol": 0, + "ff1": 3, + "ff2": 4, + "ffevol": 0, + "dd": 22, + "ddText": { + "fr": "SSO", + "nl": "ZZW", + "en": "SSW", + "de": "SSW" + }, + "wind": { + "speed": 20, + "peakSpeed": null, + "dir": 22, + "dirText": { + "fr": "SSO", + "nl": "ZZW", + "en": "SSW", + "de": "SSW" + } + }, + "precipChance": 0, + "precipQuantity": "0" + }, + { + "dayName": { + "fr": "Dimanche", + "nl": "Zondag", + "en": "Sunday", + "de": "Sonntag" + }, + "period": "9", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Zondagochtend verlaat een actieve regenzone ons land via het zuidoosten. Daarachter wordt het wisselvallig met buien. De maxima schommelen tussen 5 en 8 graden. De wind is vrij krachtig en ruimt van zuidwest naar west.", + "fr": "Dimanche matin, une zone de pluie active finira de traverser notre pays et le quittera rapidement par le sud-est. A l'arrière, on retrouvera un temps variable avec des averses. Les maxima varieront entre 5 et 8 degrés. Le vent sera assez fort et virera du sud-ouest à l'ouest. " + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60360", + "tempMin": 7, + "tempMax": 9, + "ww1": 19, + "ww2": null, + "wwevol": null, + "ff1": 4, + "ff2": 6, + "ffevol": 0, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 39, + "peakSpeed": "90", + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 100, + "precipQuantity": "14" + }, + { + "dayName": { + "fr": "Lundi", + "nl": "Maandag", + "en": "Monday", + "de": "Montag" + }, + "period": "11", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Maandag blijft het overwegend droog met tijdelijk brede opklaringen. De maxima schommelen tussen 3 en 7 graden.", + "fr": "Lundi, le temps restera généralement sec avec temporairement de larges éclaircies. Les maxima varieront entre 3 et 7 degrés. " + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60420", + "tempMin": 3, + "tempMax": 6, + "ww1": 6, + "ww2": null, + "wwevol": null, + "ff1": 5, + "ff2": null, + "ffevol": null, + "dd": 67, + "ddText": { + "fr": "OSO", + "nl": "WZW", + "en": "WSW", + "de": "WSW" + }, + "wind": { + "speed": 29, + "peakSpeed": "65", + "dir": 67, + "dirText": { + "fr": "OSO", + "nl": "WZW", + "en": "WSW", + "de": "WSW" + } + }, + "precipChance": 100, + "precipQuantity": "3" + }, + { + "dayName": { + "fr": "Mardi", + "nl": "Dinsdag", + "en": "Tuesday", + "de": "Dienstag" + }, + "period": "13", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Dinsdag komt er opnieuw meer bewolking en stijgt de kans op neerslag. Maxima rond 6 graden in het centrum van het land.", + "fr": "Mardi, on prévoit à nouveau davantage de nuages et une augmentation du risque de précipitations. Les maxima varieront autour de 6 degrés dans le centre du pays. " + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60480", + "tempMin": 2, + "tempMax": 5, + "ww1": 1, + "ww2": null, + "wwevol": null, + "ff1": 3, + "ff2": 2, + "ffevol": 0, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 12, + "peakSpeed": null, + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 50, + "precipQuantity": "0" + } + ], + "showWarningTab": false, + "graph": { + "svg": [ + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=tx&l=nl&k=893edb0ba7a2f14a0189838896ee8a2e", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=tx&l=fr&k=893edb0ba7a2f14a0189838896ee8a2e", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=tx&l=en&k=893edb0ba7a2f14a0189838896ee8a2e", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=tx&l=de&k=893edb0ba7a2f14a0189838896ee8a2e" + }, + "ratio": 1.3638709677419354 + }, + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=tn&l=nl&k=893edb0ba7a2f14a0189838896ee8a2e", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=tn&l=fr&k=893edb0ba7a2f14a0189838896ee8a2e", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=tn&l=en&k=893edb0ba7a2f14a0189838896ee8a2e", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=tn&l=de&k=893edb0ba7a2f14a0189838896ee8a2e" + }, + "ratio": 1.3638709677419354 + }, + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=rr&l=nl&k=893edb0ba7a2f14a0189838896ee8a2e", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=rr&l=fr&k=893edb0ba7a2f14a0189838896ee8a2e", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=rr&l=en&k=893edb0ba7a2f14a0189838896ee8a2e", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=rr&l=de&k=893edb0ba7a2f14a0189838896ee8a2e" + }, + "ratio": 1.3638709677419354 + } + ] + }, + "hourly": [ + { + "hour": "11", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "12", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "d" + }, + { + "hour": "13", + "temp": 11, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "14", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "15", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "d" + }, + { + "hour": "16", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1010, + "windSpeedKm": 30, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "d" + }, + { + "hour": "17", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1010, + "windSpeedKm": 30, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "18", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "19", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "20", + "temp": 10, + "ww": "15", + "precipChance": "30", + "precipQuantity": 0.02, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "21", + "temp": 10, + "ww": "18", + "precipChance": "70", + "precipQuantity": 0.79, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "22", + "temp": 9, + "ww": "18", + "precipChance": "70", + "precipQuantity": 0.16, + "pressure": 1010, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "23", + "temp": 9, + "ww": "15", + "precipChance": "30", + "precipQuantity": 0, + "pressure": 1010, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "00", + "temp": 10, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1010, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n", + "dateShow": "28/12", + "dateShowLocalized": { + "nl": "Don.", + "fr": "Jeu.", + "en": "Thu.", + "de": "Don." + } + }, + { + "hour": "01", + "temp": 10, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "02", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "03", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "04", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "05", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "06", + "temp": 10, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "07", + "temp": 9, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 30, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "08", + "temp": 9, + "ww": "0", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 30, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "09", + "temp": 9, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 30, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "10", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 30, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "11", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "12", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 25, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "13", + "temp": 11, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 35, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "14", + "temp": 11, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 30, + "windPeakSpeedKm": 60, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "15", + "temp": 11, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 25, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "16", + "temp": 11, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "17", + "temp": 10, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "18", + "temp": 10, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "19", + "temp": 10, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "20", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "21", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "22", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "23", + "temp": 9, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1010, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "00", + "temp": 9, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1010, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n", + "dateShow": "29/12", + "dateShowLocalized": { + "nl": "Vri.", + "fr": "Ven.", + "en": "Fri.", + "de": "Fre." + } + }, + { + "hour": "01", + "temp": 9, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0.02, + "pressure": 1010, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "02", + "temp": 10, + "ww": "15", + "precipChance": "20", + "precipQuantity": 0.02, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "03", + "temp": 10, + "ww": "14", + "precipChance": "40", + "precipQuantity": 0.04, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": 60, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "04", + "temp": 10, + "ww": "18", + "precipChance": "40", + "precipQuantity": 0.11, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "05", + "temp": 10, + "ww": "18", + "precipChance": "40", + "precipQuantity": 0.26, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "06", + "temp": 10, + "ww": "14", + "precipChance": "50", + "precipQuantity": 0.07, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "07", + "temp": 9, + "ww": "15", + "precipChance": "60", + "precipQuantity": 0.09, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "08", + "temp": 9, + "ww": "18", + "precipChance": "60", + "precipQuantity": 0.26, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "09", + "temp": 9, + "ww": "18", + "precipChance": "60", + "precipQuantity": 0.11, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "10", + "temp": 9, + "ww": "6", + "precipChance": "70", + "precipQuantity": 0.14, + "pressure": 1010, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "11", + "temp": 10, + "ww": "3", + "precipChance": "50", + "precipQuantity": 0.04, + "pressure": 1010, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "d" + } + ], + "warning": [] + }, + "module": [ + { + "type": "svg", + "data": { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=pollen&l=nl&k=893edb0ba7a2f14a0189838896ee8a2e", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=pollen&l=fr&k=893edb0ba7a2f14a0189838896ee8a2e", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=pollen&l=en&k=893edb0ba7a2f14a0189838896ee8a2e", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=pollen&l=de&k=893edb0ba7a2f14a0189838896ee8a2e" + }, + "ratio": 3.0458333333333334 + } + }, + { + "type": "uv", + "data": { + "levelValue": 0.6, + "level": { + "nl": "Laag", + "fr": "Faible", + "en": "Low", + "de": "Niedrig" + }, + "title": { + "nl": "Uv-index", + "fr": "Indice UV", + "en": "UV Index", + "de": "UV Index" + } + } + }, + { + "type": "observation", + "data": { + "count": 313, + "title": { + "nl": "Waarnemingen vandaag", + "fr": "Observations d'aujourd'hui", + "en": "Today's Observations", + "de": "Beobachtungen heute" + } + } + }, + { + "type": "svg", + "data": { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=nl&k=893edb0ba7a2f14a0189838896ee8a2e", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=fr&k=893edb0ba7a2f14a0189838896ee8a2e", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=en&k=893edb0ba7a2f14a0189838896ee8a2e", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=de&k=893edb0ba7a2f14a0189838896ee8a2e" + }, + "ratio": 1.6587926509186353 + } + } + ], + "animation": { + "localisationLayer": "https://app.meteo.be/services/appv4/?s=getLocalizationLayer&lat=50.797798&long=4.35811&f=2&k=3040f09e112c427d871465dc145bc9eb", + "localisationLayerRatioX": 0.5821, + "localisationLayerRatioY": 0.4118, + "speed": 0.3, + "type": "10min", + "unit": { + "fr": "mm/10min", + "nl": "mm/10min", + "en": "mm/10min", + "de": "mm/10min" + }, + "country": "BE", + "sequence": [ + { + "time": "2023-12-27T10:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312270910&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T10:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312270920&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T10:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312270930&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T10:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312270940&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T10:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312270950&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T10:50:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271000&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T11:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271010&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T11:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271020&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T11:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271030&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T11:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271040&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T11:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271050&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T11:50:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271100&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T12:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271110&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T12:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271120&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T12:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271130&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T12:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271140&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T12:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271150&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T12:50:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271200&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T13:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271210&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T13:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271220&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T13:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271230&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T13:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271240&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T13:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271250&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T13:50:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271300&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T14:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271310&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T14:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271320&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T14:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271330&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T14:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271340&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T14:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271350&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T14:50:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271400&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + } + ], + "threshold": [], + "sequenceHint": { + "nl": "Geen regen voorzien op korte termijn", + "fr": "Pas de pluie prévue prochainement", + "en": "No rain forecasted shortly", + "de": "Kein Regen erwartet in naher Zukunft" + } + }, + "todayObsCount": 313 +} diff --git a/tests/components/irm_kmi/fixtures/high_low_temp.json b/tests/components/irm_kmi/fixtures/high_low_temp.json new file mode 100644 index 000000000000..f1b0e020a4a6 --- /dev/null +++ b/tests/components/irm_kmi/fixtures/high_low_temp.json @@ -0,0 +1,1635 @@ +{ + "cityName": "Namur", + "country": "BE", + "obs": { + "temp": 4, + "timestamp": "2024-01-21T14:10:00+01:00", + "ww": 15, + "dayNight": "d" + }, + "for": { + "daily": [ + { + "dayName": { + "fr": "Dimanche", + "nl": "Zondag", + "en": "Sunday", + "de": "Sonntag" + }, + "period": "1", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Deze namiddag is het vaak bewolkt en droog, op wat lokaal gedruppel na. Het wordt zachter met maxima van 1 of 2 graden in de Ardennen, 5 graden in het centrum tot 8 graden aan zee. De wind uit zuid tot zuidwest wordt soms vrij krachtig in het binnenland en krachtig aan zee. Verspreid over het land zijn er rukwinden mogelijk tussen 50 en 60 km/h.", + "fr": "Cet après-midi, il fera souvent nuageux mais sec à quelques gouttes près. Le temps sera plus doux avec des maxima de 1 ou 2 degrés en Ardenne, 5 degrés dans le centre jusqu'à 8 degrés à la mer. Le vent de sud à sud-ouest deviendra parfois assez fort dans l'intérieur et fort à la côte avec des rafales de 50 à 60 km/h." + }, + "dawnRiseSeconds": "30780", + "dawnSetSeconds": "62100", + "tempMin": null, + "tempMax": 3, + "ww1": 3, + "ww2": null, + "wwevol": null, + "ff1": 5, + "ff2": 4, + "ffevol": 1, + "dd": 22, + "ddText": { + "fr": "SSO", + "nl": "ZZW", + "en": "SSW", + "de": "SSW" + }, + "wind": { + "speed": 29, + "peakSpeed": "50", + "dir": 22, + "dirText": { + "fr": "SSO", + "nl": "ZZW", + "en": "SSW", + "de": "SSW" + } + }, + "precipChance": 0, + "precipQuantity": "0" + }, + { + "dayName": { + "fr": "Cette nuit", + "nl": "Vannacht", + "en": "Tonight", + "de": "heute abend" + }, + "period": "2", + "day_night": "0", + "dayNight": "n", + "text": { + "nl": "Vanavond en tijdens het eerste deel van de nacht is het bewolkt en meestal droog. Rond middernacht bereikt een regenzone ons land vanaf de kust en trekt verder oostwaarts. Dit gaat gepaard met meer wind. De wind uit zuidzuidwest spant aan tot krachtig in het binnenland en zeer krachtig aan zee met windstoten tussen 80 en 90 km/h (of zeer plaatselijk iets meer). De minima worden al vroeg tijdens de avond bereikt en liggen tussen 2 en 8 graden. Op het einde van de nacht klimmen de temperaturen naar waarden tussen 4 en 10 graden.", + "fr": "Ce soir et en première partie de nuit, le temps sera encore généralement sec. Autour de minuit, une zone de pluie atteindra le littoral avant de gagner les autres régions. Le vent de sud-sud-ouest se renforcera nettement pour devenir fort dans l'intérieur et très fort à la mer, avec des rafales de 80 à 90 km/h (ou très localement davantage). Les minima oscilleront entre 2 et 8 degrés (atteints en soirée). En fin de nuit, on relèvera 4 à 10 degrés." + }, + "dawnRiseSeconds": "30780", + "dawnSetSeconds": "62100", + "tempMin": 4, + "tempMax": null, + "ww1": 15, + "ww2": null, + "wwevol": null, + "ff1": 5, + "ff2": 6, + "ffevol": 0, + "dd": 22, + "ddText": { + "fr": "SSO", + "nl": "ZZW", + "en": "SSW", + "de": "SSW" + }, + "wind": { + "speed": 39, + "peakSpeed": "80", + "dir": 22, + "dirText": { + "fr": "SSO", + "nl": "ZZW", + "en": "SSW", + "de": "SSW" + } + }, + "precipChance": 35, + "precipQuantity": "0" + }, + { + "dayName": { + "fr": "Lundi", + "nl": "Maandag", + "en": "Monday", + "de": "Montag" + }, + "period": "3", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Maandag bereikt al snel een nieuwe regenzone ons land vanaf het westen. Aan de achterzijde hiervan wordt het grotendeels droog met brede opklaringen. De opklaringen doen zowel Laag- als Midden-België aan, in Hoog-België blijft het vermoedelijk bewolkt en regenachtig. Het wordt nog iets zachter bij maxima tussen 5 en 9 graden ten zuiden van Samber en Maas en 10 of 11 graden elders. In de voormiddag is de wind vaak nog krachtig in het binnenland en zeer krachtig aan zee met rukwinden tussen 80 en 90 km/h. In de namiddag, na de passage van de regenzone, ruimt de wind naar westelijke richtingen en wordt hij matig tot soms vrij krachtig in het binnenland en krachtig aan zee met rukwinden tussen 50 en 60 km/h.\n\nMaandagavond en -nacht is het vrijwel helder met soms enkele hoge wolkensluiers in Laag- en Midden-België. In Hoog-België domineren de lage wolkenvelden en kan er soms nog wat lichte regen of winterse neerslag vallen. De minima liggen tussen 1 en 6 graden. De wind uit westelijke richtingen is matig tot vrij krachtig in het binnenland en krachtig aan zee.", + "fr": "Lundi, une nouvelle zone de pluie atteindra rapidement le pays par l'ouest, suivie de belles éclaircies. En Haute Belgique, le temps restera pluvieux. Il fera encore plus doux avec des maxima de 5 à 9 degrés au sud du sillon Sambre et Meuse et de 10 ou 11 degrés ailleurs. Le vent sera encore assez fort le matin dans l'intérieur et très fort à la mer, avec des pointes de 80 à 90 km/h. L'après-midi, le vent tournera vers l'ouest et deviendra modéré à parfois assez fort, fort à la côte, avec des rafales de 50 à 60 km/h.\n\nLundi soir et la nuit de lundi à mardi, il fera peu nuageux avec parfois quelques voiles d'altitude. En Haute Belgique, les nuages bas domineront encore le ciel avec le risque de faibles pluies ou de précipitations hivernales. Les minima se situeront entre 1 et 6 degrés. Le vent de secteur ouest sera modéré à assez fort dans l'intérieur et fort à la mer." + }, + "dawnRiseSeconds": "30720", + "dawnSetSeconds": "62160", + "tempMin": 1, + "tempMax": 10, + "ww1": 18, + "ww2": 6, + "wwevol": 0, + "ff1": 6, + "ff2": 4, + "ffevol": 0, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 39, + "peakSpeed": "80", + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 100, + "precipQuantity": "1" + }, + { + "dayName": { + "fr": "Mardi", + "nl": "Dinsdag", + "en": "Tuesday", + "de": "Dienstag" + }, + "period": "5", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Dinsdagochtend is het op veel plaatsen zonnig met hoge wolkenvelden. In de Ardennen begint de dag grijs met lokala mist. Vanaf het westen neemt de bewolking toe en volgt er regen. De maxima worden pas 's avonds laat bereikt; ze liggen dan tussen 7 of 8 graden in de Hoge Venen en 11 graden in Laag-België. De wind waait matig uit zuidwest, toenemend tot vrij krachtig en aan zee tot krachtig. Er zijn rukwinden mogelijk tot zo'n 60 km/h.", + "fr": "Mardi, la matinée sera souvent ensoleillée avec des voiles de nuages élevés. Les nuages bas et la grisaille recouvriront l'Ardenne. En cours de journée, la nébulosité augmentera à partir de l'ouest et des pluies suivront. Les maxima seront atteints en soirée et varieront entre 7 ou 8 degrés dans les Hautes Fagnes et 11 degrés en Basse Belgique. Le vent modéré de sud-ouest deviendra assez fort et même parfois fort le long du littoral avec des rafales autour de 60 km/h." + }, + "dawnRiseSeconds": "30660", + "dawnSetSeconds": "62280", + "tempMin": 3, + "tempMax": 8, + "ww1": 3, + "ww2": 18, + "wwevol": 0, + "ff1": 3, + "ff2": 5, + "ffevol": 0, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 29, + "peakSpeed": "55", + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 50, + "precipQuantity": "1" + }, + { + "dayName": { + "fr": "Mercredi", + "nl": "Woensdag", + "en": "Wednesday", + "de": "Mittwoch" + }, + "period": "7", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Woensdagvoormiddag trekt een regenzone snel van noordwest naar zuidoost. In Vlaanderen wordt het snel droog met brede opklaringen. In de gebieden ten zuiden van Samber en Maas blijft het een groot deel van de dag grijs en regenachtig. De maxima liggen rond 6 of 7 graden in de Hoge Venen, rond 10 graden aan zee en rond 12 graden in het centrum. De wind waait vrij krachtig tot krachtig uit westzuidwest met rukwinden rond 65 km/h. Op het einde van de dag neemt de wind af.", + "fr": "Mercredi, une zone de pluie traversera notre pays du nord-ouest vers le sud-est. En Flandre, de larges éclaircies s'établiront rapidement mais la nébulosité restera abondante dans le sud du pays avec de la pluie. Les maxima oscilleront entre 6 ou 7 degrés en Hautes Fagnes, 10 degrés à la mer et 12 degrés dans le centre. Le vent sera assez fort à fort d'ouest-sud-ouest avec des pointes de 65 km/h. En fin de journée, le vent se calmera." + }, + "dawnRiseSeconds": "30600", + "dawnSetSeconds": "62340", + "tempMin": 12, + "tempMax": 10, + "ww1": 18, + "ww2": 4, + "wwevol": 0, + "ff1": 5, + "ff2": null, + "ffevol": null, + "dd": 90, + "ddText": { + "fr": "O", + "nl": "W", + "en": "W", + "de": "W" + }, + "wind": { + "speed": 29, + "peakSpeed": "70", + "dir": 90, + "dirText": { + "fr": "O", + "nl": "W", + "en": "W", + "de": "W" + } + }, + "precipChance": 100, + "precipQuantity": "2" + }, + { + "dayName": { + "fr": "Jeudi", + "nl": "Donderdag", + "en": "Thursday", + "de": "Donnerstag" + }, + "period": "9", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Donderdag start zonnig met hoge wolkenvelden. Ten zuiden van Samber en Maas begint de dag grijs met lage wolken en/of mist, die hardnekkig kunnen zijn. Geleidelijk neemt de bewolking toe vanaf het westen gevolgd door wat lichte regen. De maxima schommelen rond 9 graden in het centrum.", + "fr": "Jeudi, il fera d'abord ensoleillé avec des nuages élevés. Au sud du sillon Sambre et Meuse, le temps sera encore gris avec des nuages bas et/ou du brouillard tenace. Une faible zone de pluie suivra par l'ouest. Les maxima varieront autour de 9 degrés dans le centre." + }, + "dawnRiseSeconds": "30540", + "dawnSetSeconds": "62460", + "tempMin": 2, + "tempMax": 8, + "ww1": 15, + "ww2": null, + "wwevol": null, + "ff1": 2, + "ff2": 3, + "ffevol": 0, + "dd": 0, + "ddText": { + "fr": "S", + "nl": "Z", + "en": "S", + "de": "S" + }, + "wind": { + "speed": 12, + "peakSpeed": null, + "dir": 0, + "dirText": { + "fr": "S", + "nl": "Z", + "en": "S", + "de": "S" + } + }, + "precipChance": 0, + "precipQuantity": "0" + }, + { + "dayName": { + "fr": "Vendredi", + "nl": "Vrijdag", + "en": "Friday", + "de": "Freitag" + }, + "period": "11", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Vrijdag begint zwaarbewolkt met wat regen maar vanaf de kust wordt het vrij snel droog en vrij zonnig. In het zuiden blijft het veelal grijs met nog kans op buien. De maxima liggen in de buurt van 9 of 10 graden in het centrum.", + "fr": "Vendredi, il fera très nuageux avec un peu de pluie. Une belle amélioration se dessinera rapidement depuis la côte, excepté dans le sud du pays. Les maxima oscilleront autour de 9 ou 10 degrés dans le centre." + }, + "dawnRiseSeconds": "30480", + "dawnSetSeconds": "62580", + "tempMin": 6, + "tempMax": 8, + "ww1": 19, + "ww2": null, + "wwevol": null, + "ff1": 4, + "ff2": null, + "ffevol": null, + "dd": 135, + "ddText": { + "fr": "NO", + "nl": "NW", + "en": "NW", + "de": "NW" + }, + "wind": { + "speed": 20, + "peakSpeed": "50", + "dir": 135, + "dirText": { + "fr": "NO", + "nl": "NW", + "en": "NW", + "de": "NW" + } + }, + "precipChance": 100, + "precipQuantity": "2" + }, + { + "dayName": { + "fr": "Samedi", + "nl": "Zaterdag", + "en": "Saturday", + "de": "Samstag" + }, + "period": "13", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Zaterdag is het vaak zonnig met middelhoge en hoge wolkenvelden. Later op de dag wordt de middelhoge bewolking wat dikker. De maxima liggen rond 7 graden in het centrum.", + "fr": "Samedi, il fera ensoleillé avec des champs nuageux de moyenne et de haute altitude. En cours de journée, la couverture de nuages moyens s'épaissira. Les maxima se situeront autour de 7 degrés dans le centre." + }, + "dawnRiseSeconds": "30360", + "dawnSetSeconds": "62700", + "tempMin": -2, + "tempMax": 6, + "ww1": 3, + "ww2": null, + "wwevol": null, + "ff1": 2, + "ff2": 3, + "ffevol": 0, + "dd": 0, + "ddText": { + "fr": "S", + "nl": "Z", + "en": "S", + "de": "S" + }, + "wind": { + "speed": 12, + "peakSpeed": null, + "dir": 0, + "dirText": { + "fr": "S", + "nl": "Z", + "en": "S", + "de": "S" + } + }, + "precipChance": 0, + "precipQuantity": "0" + } + ], + "showWarningTab": true, + "graph": { + "svg": [ + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tx&l=nl&k=32003c7eac2900f3d73c50f9e27330ab", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tx&l=fr&k=32003c7eac2900f3d73c50f9e27330ab", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tx&l=en&k=32003c7eac2900f3d73c50f9e27330ab", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tx&l=de&k=32003c7eac2900f3d73c50f9e27330ab" + }, + "ratio": 1.3638709677419354 + }, + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tn&l=nl&k=32003c7eac2900f3d73c50f9e27330ab", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tn&l=fr&k=32003c7eac2900f3d73c50f9e27330ab", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tn&l=en&k=32003c7eac2900f3d73c50f9e27330ab", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tn&l=de&k=32003c7eac2900f3d73c50f9e27330ab" + }, + "ratio": 1.3638709677419354 + }, + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=rr&l=nl&k=32003c7eac2900f3d73c50f9e27330ab", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=rr&l=fr&k=32003c7eac2900f3d73c50f9e27330ab", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=rr&l=en&k=32003c7eac2900f3d73c50f9e27330ab", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=rr&l=de&k=32003c7eac2900f3d73c50f9e27330ab" + }, + "ratio": 1.3638709677419354 + } + ] + }, + "hourly": [ + { + "hour": "14", + "temp": 3, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1022, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "15", + "temp": 3, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1022, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "d" + }, + { + "hour": "16", + "temp": 2, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1022, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "d" + }, + { + "hour": "17", + "temp": 1, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1022, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "d" + }, + { + "hour": "18", + "temp": 1, + "ww": "14", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1021, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "19", + "temp": 2, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1021, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "20", + "temp": 2, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1020, + "windSpeedKm": 35, + "windPeakSpeedKm": 60, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "21", + "temp": 3, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1019, + "windSpeedKm": 35, + "windPeakSpeedKm": 65, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "22", + "temp": 4, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1018, + "windSpeedKm": 40, + "windPeakSpeedKm": 70, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "23", + "temp": 4, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0.01, + "pressure": 1017, + "windSpeedKm": 40, + "windPeakSpeedKm": 70, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "00", + "temp": 5, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0.08, + "pressure": 1016, + "windSpeedKm": 40, + "windPeakSpeedKm": 75, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n", + "dateShow": "22/01", + "dateShowLocalized": { + "nl": "Maa.", + "fr": "Lun.", + "en": "Mon.", + "de": "Mon." + } + }, + { + "hour": "01", + "temp": 6, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0.01, + "pressure": 1014, + "windSpeedKm": 45, + "windPeakSpeedKm": 75, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "02", + "temp": 7, + "ww": "18", + "precipChance": "20", + "precipQuantity": 0.1, + "pressure": 1014, + "windSpeedKm": 45, + "windPeakSpeedKm": 75, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "03", + "temp": 7, + "ww": "15", + "precipChance": "30", + "precipQuantity": 0.03, + "pressure": 1012, + "windSpeedKm": 45, + "windPeakSpeedKm": 80, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "04", + "temp": 8, + "ww": "18", + "precipChance": "30", + "precipQuantity": 0.21, + "pressure": 1011, + "windSpeedKm": 45, + "windPeakSpeedKm": 80, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "05", + "temp": 8, + "ww": "15", + "precipChance": "30", + "precipQuantity": 0.06, + "pressure": 1011, + "windSpeedKm": 45, + "windPeakSpeedKm": 80, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "06", + "temp": 9, + "ww": "15", + "precipChance": "30", + "precipQuantity": 0.09, + "pressure": 1011, + "windSpeedKm": 45, + "windPeakSpeedKm": 80, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "07", + "temp": 9, + "ww": "18", + "precipChance": "30", + "precipQuantity": 0.11, + "pressure": 1010, + "windSpeedKm": 45, + "windPeakSpeedKm": 80, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "08", + "temp": 9, + "ww": "14", + "precipChance": "30", + "precipQuantity": 0.04, + "pressure": 1011, + "windSpeedKm": 40, + "windPeakSpeedKm": 75, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "09", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0.02, + "pressure": 1011, + "windSpeedKm": 35, + "windPeakSpeedKm": 70, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "10", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0.04, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 65, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "11", + "temp": 9, + "ww": "3", + "precipChance": "30", + "precipQuantity": 0.06, + "pressure": 1012, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "d" + }, + { + "hour": "12", + "temp": 10, + "ww": "6", + "precipChance": "40", + "precipQuantity": 0.7100000000000001, + "pressure": 1012, + "windSpeedKm": 30, + "windPeakSpeedKm": 60, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "d" + }, + { + "hour": "13", + "temp": 9, + "ww": "18", + "precipChance": "40", + "precipQuantity": 0.22, + "pressure": 1012, + "windSpeedKm": 30, + "windPeakSpeedKm": 60, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "d" + }, + { + "hour": "14", + "temp": 8, + "ww": "15", + "precipChance": "40", + "precipQuantity": 0.03, + "pressure": 1012, + "windSpeedKm": 25, + "windPeakSpeedKm": 60, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "d" + }, + { + "hour": "15", + "temp": 7, + "ww": "3", + "precipChance": "20", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "d" + }, + { + "hour": "16", + "temp": 7, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "d" + }, + { + "hour": "17", + "temp": 6, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1014, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "d" + }, + { + "hour": "18", + "temp": 6, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1015, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "19", + "temp": 5, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1016, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "20", + "temp": 5, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1017, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "21", + "temp": 5, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1018, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "22", + "temp": 5, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0.02, + "pressure": 1019, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "23", + "temp": 5, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1019, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "00", + "temp": 5, + "ww": "4", + "precipChance": "20", + "precipQuantity": 0.1, + "pressure": 1020, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n", + "dateShow": "23/01", + "dateShowLocalized": { + "nl": "Din.", + "fr": "Mar.", + "en": "Tue.", + "de": "Die." + } + }, + { + "hour": "01", + "temp": 5, + "ww": "1", + "precipChance": "10", + "precipQuantity": 0.01, + "pressure": 1022, + "windSpeedKm": 25, + "windPeakSpeedKm": 55, + "windDirection": 90, + "windDirectionText": { + "nl": "W", + "fr": "O", + "en": "W", + "de": "W" + }, + "dayNight": "n" + }, + { + "hour": "02", + "temp": 4, + "ww": "1", + "precipChance": "10", + "precipQuantity": 0, + "pressure": 1022, + "windSpeedKm": 25, + "windPeakSpeedKm": 50, + "windDirection": 90, + "windDirectionText": { + "nl": "W", + "fr": "O", + "en": "W", + "de": "W" + }, + "dayNight": "n" + }, + { + "hour": "03", + "temp": 4, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1023, + "windSpeedKm": 20, + "windPeakSpeedKm": null, + "windDirection": 90, + "windDirectionText": { + "nl": "W", + "fr": "O", + "en": "W", + "de": "W" + }, + "dayNight": "n" + }, + { + "hour": "04", + "temp": 4, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1024, + "windSpeedKm": 20, + "windPeakSpeedKm": null, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "05", + "temp": 3, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1025, + "windSpeedKm": 20, + "windPeakSpeedKm": null, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "06", + "temp": 3, + "ww": "0", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1026, + "windSpeedKm": 20, + "windPeakSpeedKm": null, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "07", + "temp": 3, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1026, + "windSpeedKm": 15, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "08", + "temp": 3, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1027, + "windSpeedKm": 15, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "09", + "temp": 3, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1028, + "windSpeedKm": 20, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "10", + "temp": 4, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1028, + "windSpeedKm": 20, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "11", + "temp": 6, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1028, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "12", + "temp": 6, + "ww": "15", + "precipChance": "20", + "precipQuantity": 0, + "pressure": 1029, + "windSpeedKm": 20, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "13", + "temp": 6, + "ww": "15", + "precipChance": "40", + "precipQuantity": 0.09, + "pressure": 1028, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "14", + "temp": 7, + "ww": "18", + "precipChance": "60", + "precipQuantity": 0.2, + "pressure": 1027, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "d" + } + ], + "warning": [ + { + "icon_country": "BE", + "warningType": { + "id": "0", + "name": { + "fr": "Vent", + "nl": "Wind", + "en": "Wind", + "de": "Wind" + } + }, + "warningLevel": "1", + "text": { + "fr": "Ce soir et cette nuit, le vent se renforcera progressivement pour devenir fort dans l'intérieur et très fort à la mer. Des rafales de 80 à 90 km/h pourront se produire (très localement un peu plus). Lundi après-midi, les rafales se limiteront à des valeurs comprises entre 50 et 60 km/h.", + "nl": "Vanavond en vannacht spant de wind aan en wordt hij krachtig in het binnenland en zeer krachtig aan zee met rukwinden tussen 80 en 90 km/h (of zeer lokaal iets meer). Maandagnamiddag neemt hij af in kracht en zijn nog rukwinden mogelijk tussen 50 en 60 km/h.", + "en": "There is a strong wind expected where local troubles or damage is possible and traffic congestion may arise. Be careful.", + "de": "Es wird viel Wind erwartet, wobei lokale Beeinträchtigungen und Verkehrshindernisse entstehen können. Seien Sie vorsichtig." + }, + "fromTimestamp": "2024-01-21T23:00:00+01:00", + "toTimestamp": "2024-01-22T13:00:00+01:00" + } + ] + }, + "module": [ + { + "type": "uv", + "data": { + "levelValue": 0.7, + "level": { + "nl": "Laag", + "fr": "Faible", + "en": "Low", + "de": "Niedrig" + }, + "title": { + "nl": "Uv-index", + "fr": "Indice UV", + "en": "UV Index", + "de": "UV Index" + } + } + }, + { + "type": "observation", + "data": { + "count": 773, + "title": { + "nl": "Waarnemingen vandaag", + "fr": "Observations d'aujourd'hui", + "en": "Today's Observations", + "de": "Beobachtungen heute" + } + } + }, + { + "type": "svg", + "data": { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=nl&k=32003c7eac2900f3d73c50f9e27330ab", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=fr&k=32003c7eac2900f3d73c50f9e27330ab", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=en&k=32003c7eac2900f3d73c50f9e27330ab", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=de&k=32003c7eac2900f3d73c50f9e27330ab" + }, + "ratio": 1.6587926509186353 + } + } + ], + "animation": { + "localisationLayer": "https://app.meteo.be/services/appv4/?s=getLocalizationLayerBE&ins=92094&f=2&k=83d708d73ec391c032e6d5fb70f7e71a", + "localisationLayerRatioX": 0.6667, + "localisationLayerRatioY": 0.523, + "speed": 0.3, + "type": "10min", + "unit": { + "fr": "mm/10min", + "nl": "mm/10min", + "en": "mm/10min", + "de": "mm/10min" + }, + "country": "BE", + "sequence": [ + { + "time": "2024-01-21T13:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211210&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T13:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211220&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T13:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211230&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T13:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211240&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T13:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211250&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T13:50:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211300&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T14:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211310&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T14:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211320&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T14:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211330&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T14:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211340&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T14:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211350&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T14:50:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211400&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T15:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211410&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T15:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211420&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T15:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211430&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T15:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211440&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T15:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211450&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T15:50:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211500&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T16:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211510&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T16:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211520&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T16:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211530&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T16:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211540&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T16:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211550&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T16:50:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211600&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T17:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211610&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T17:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211620&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T17:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211630&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T17:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211640&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T17:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211650&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T17:50:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211700&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + } + ], + "threshold": [], + "sequenceHint": { + "nl": "Geen regen voorzien op korte termijn", + "fr": "Pas de pluie prévue prochainement", + "en": "No rain forecasted shortly", + "de": "Kein Regen erwartet in naher Zukunft" + } + }, + "todayObsCount": 773 +} diff --git a/tests/components/irm_kmi/snapshots/test_weather.ambr b/tests/components/irm_kmi/snapshots/test_weather.ambr new file mode 100644 index 000000000000..a8a0c92b5399 --- /dev/null +++ b/tests/components/irm_kmi/snapshots/test_weather.ambr @@ -0,0 +1,694 @@ +# serializer version: 1 +# name: test_forecast_service[daily] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'condition': 'pouring', + 'condition_2': None, + 'condition_evol': , + 'datetime': '2023-12-28', + 'is_daytime': True, + 'precipitation': 0.1, + 'precipitation_probability': None, + 'sunrise': '2023-12-28T08:47:43+01:00', + 'sunset': '2023-12-28T16:34:06+01:00', + 'temperature': 11.0, + 'templow': 9.0, + 'text': ''' + Waarschuwingen + Vanavond zijn er in het noordwesten zware windstoten mogelijk van 75-90 km/uur (code geel). + + Vanochtend is het half bewolkt met in het noorden kans op een bui. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, af en toe even stormachtig, windkracht 8. Aan de kust komen windstoten voor van ongeveer 80 km/uur. + Vanmiddag is het half tot zwaar bewolkt met kans op een bui, vooral in het noorden en westen. De middagtemperatuur ligt rond 11°C. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, vooral later ook af en toe stormachtig, windkracht 8. Aan de kust zijn er windstoten tot ongeveer 80 km/uur. + Vanavond zijn er buien, alleen in het zuidoosten is het overwegend droog. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust hard tot stormachtig, windkracht 7 tot 8. Vooral in het noordwesten zijn windstoten mogelijk van 75-90 km/uur. + + Komende nacht komen er enkele buien voor. Met een minimumtemperatuur van ongeveer 8°C is het zacht. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met zware windstoten tot ongeveer 80 km/uur. + + Morgenochtend is het half tot zwaar bewolkt en zijn er enkele buien. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig hard, windkracht 6-7, met vooral in het noordwesten mogelijk zware windstoten tot ongeveer 80 km/uur. + Morgenmiddag is er af en toe ruimte voor de zon en blijft het op de meeste plaatsen droog, alleen in het zuidoosten kan een enkele bui vallen. Met middagtemperaturen van ongeveer 10°C blijft het zacht. De wind uit het zuidwesten is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met in het Waddengebied zware windstoten tot ongeveer 80 km/uur. + Morgenavond is het half tot zwaar bewolkt met een enkele bui. De wind komt uit het zuidwesten en is meest matig, aan de kust krachtig tot hard, windkracht 6-7, boven de Wadden eerst stormachtig, windkracht 8. + (Bron: KNMI, 2023-12-28T06:56:00+01:00) + + ''', + 'wind_bearing': 225.0, + 'wind_gust_speed': 33.0, + 'wind_speed': 32.0, + }), + dict({ + 'condition': 'partlycloudy', + 'condition_2': None, + 'condition_evol': , + 'datetime': '2023-12-29', + 'is_daytime': True, + 'precipitation': 3.8, + 'precipitation_probability': None, + 'sunrise': '2023-12-29T08:47:48+01:00', + 'sunset': '2023-12-29T16:35:00+01:00', + 'temperature': 10.0, + 'text': '', + 'wind_bearing': 248.0, + 'wind_gust_speed': 28.0, + 'wind_speed': 26.0, + }), + dict({ + 'condition': 'pouring', + 'condition_2': None, + 'condition_evol': , + 'datetime': '2023-12-30', + 'is_daytime': True, + 'precipitation': 1.7, + 'precipitation_probability': None, + 'sunrise': '2023-12-30T08:47:49+01:00', + 'sunset': '2023-12-30T16:35:57+01:00', + 'temperature': 10.0, + 'templow': 5.0, + 'text': '', + 'wind_bearing': 225.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 22.0, + }), + dict({ + 'condition': 'pouring', + 'condition_2': None, + 'condition_evol': , + 'datetime': '2023-12-31', + 'is_daytime': True, + 'precipitation': 4.2, + 'precipitation_probability': None, + 'sunrise': '2023-12-31T08:47:47+01:00', + 'sunset': '2023-12-31T16:36:56+01:00', + 'temperature': 9.0, + 'templow': 7.0, + 'text': '', + 'wind_bearing': 203.0, + 'wind_gust_speed': 31.0, + 'wind_speed': 30.0, + }), + dict({ + 'condition': 'pouring', + 'condition_2': None, + 'condition_evol': , + 'datetime': '2024-01-01', + 'is_daytime': True, + 'precipitation': 2.2, + 'precipitation_probability': None, + 'sunrise': '2024-01-01T08:47:42+01:00', + 'sunset': '2024-01-01T16:37:59+01:00', + 'temperature': 7.0, + 'templow': 5.0, + 'text': '', + 'wind_bearing': 225.0, + 'wind_gust_speed': 28.0, + 'wind_speed': 23.0, + }), + dict({ + 'condition': 'pouring', + 'condition_2': None, + 'condition_evol': , + 'datetime': '2024-01-02', + 'is_daytime': True, + 'precipitation': 1.4, + 'precipitation_probability': None, + 'sunrise': '2024-01-02T08:47:32+01:00', + 'sunset': '2024-01-02T16:39:04+01:00', + 'temperature': 6.0, + 'templow': 3.0, + 'text': '', + 'wind_bearing': 225.0, + 'wind_gust_speed': 16.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'pouring', + 'condition_2': None, + 'condition_evol': , + 'datetime': '2024-01-03', + 'is_daytime': True, + 'precipitation': 1.0, + 'precipitation_probability': None, + 'sunrise': '2024-01-03T08:47:20+01:00', + 'sunset': '2024-01-03T16:40:12+01:00', + 'temperature': 6.0, + 'templow': 3.0, + 'text': '', + 'wind_bearing': 203.0, + 'wind_gust_speed': 14.0, + 'wind_speed': 13.0, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[hourly] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-22T15:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1008.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 33.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-22T16:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1008.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 32.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-22T17:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1007.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 32.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-22T18:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1007.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-22T19:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-22T20:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-22T21:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2025-09-22T22:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.7, + 'precipitation_probability': 70, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2025-09-22T23:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.1, + 'precipitation_probability': 10, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 37.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-23T00:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 20, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2025-09-23T01:00:00+02:00', + 'is_daytime': False, + 'precipitation': 1.9, + 'precipitation_probability': 80, + 'pressure': 1005.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 31.0, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2025-09-23T02:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.6, + 'precipitation_probability': 70, + 'pressure': 1005.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 38.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T03:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-23T04:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 34.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T05:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 9.0, + 'wind_bearing': 248.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-23T06:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 9.0, + 'wind_bearing': 248.0, + 'wind_speed': 34.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T07:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 9.0, + 'wind_bearing': 248.0, + 'wind_speed': 32.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T08:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 9.0, + 'wind_bearing': 248.0, + 'wind_speed': 31.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T09:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 9.0, + 'wind_bearing': 248.0, + 'wind_speed': 31.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-23T10:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 9.0, + 'wind_bearing': 248.0, + 'wind_speed': 32.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T11:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 32.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T12:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 34.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T13:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 33.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T14:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 31.0, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2025-09-23T15:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 28.0, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2025-09-23T16:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 9.0, + 'wind_bearing': 248.0, + 'wind_speed': 24.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2025-09-23T17:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 8.0, + 'wind_bearing': 225.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T18:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 8.0, + 'wind_bearing': 225.0, + 'wind_speed': 18.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-23T19:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 8.0, + 'wind_bearing': 203.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2025-09-23T20:00:00+02:00', + 'is_daytime': False, + 'precipitation': 5.7, + 'precipitation_probability': 100, + 'pressure': 1005.0, + 'temperature': 8.0, + 'wind_bearing': 248.0, + 'wind_speed': 22.0, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2025-09-23T21:00:00+02:00', + 'is_daytime': False, + 'precipitation': 3.8, + 'precipitation_probability': 100, + 'pressure': 1006.0, + 'temperature': 7.0, + 'wind_bearing': 270.0, + 'wind_speed': 26.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T22:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 8.0, + 'wind_bearing': 248.0, + 'wind_speed': 24.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-23T23:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1007.0, + 'temperature': 7.0, + 'wind_bearing': 248.0, + 'wind_speed': 22.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-24T00:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1008.0, + 'temperature': 8.0, + 'wind_bearing': 270.0, + 'wind_speed': 26.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2025-09-24T01:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1007.0, + 'temperature': 7.0, + 'wind_bearing': 270.0, + 'wind_speed': 26.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2025-09-24T02:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1008.0, + 'temperature': 7.0, + 'wind_bearing': 270.0, + 'wind_speed': 24.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-24T03:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1008.0, + 'temperature': 7.0, + 'wind_bearing': 270.0, + 'wind_speed': 24.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2025-09-24T04:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1009.0, + 'temperature': 7.0, + 'wind_bearing': 248.0, + 'wind_speed': 23.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2025-09-24T05:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1009.0, + 'temperature': 6.0, + 'wind_bearing': 248.0, + 'wind_speed': 23.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-24T06:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1009.0, + 'temperature': 6.0, + 'wind_bearing': 248.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-24T07:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1010.0, + 'temperature': 6.0, + 'wind_bearing': 248.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-24T08:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1011.0, + 'temperature': 6.0, + 'wind_bearing': 248.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2025-09-24T09:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1011.0, + 'temperature': 6.0, + 'wind_bearing': 248.0, + 'wind_speed': 13.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-24T10:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1012.0, + 'temperature': 5.0, + 'wind_bearing': 225.0, + 'wind_speed': 12.0, + }), + ]), + }), + }) +# --- +# name: test_weather_nl[weather.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': 'weather', + 'entity_category': None, + 'entity_id': 'weather.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': None, + 'platform': 'irm_kmi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'city country', + 'unit_of_measurement': None, + }) +# --- +# name: test_weather_nl[weather.home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data from the Royal Meteorological Institute of Belgium meteo.be', + 'friendly_name': 'Home', + 'precipitation_unit': , + 'pressure': 1008.0, + 'pressure_unit': , + 'supported_features': , + 'temperature': 11.0, + 'temperature_unit': , + 'uv_index': 1, + 'visibility_unit': , + 'wind_bearing': 225.0, + 'wind_speed': 40.0, + 'wind_speed_unit': , + }), + 'context': , + 'entity_id': 'weather.home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cloudy', + }) +# --- diff --git a/tests/components/irm_kmi/test_config_flow.py b/tests/components/irm_kmi/test_config_flow.py new file mode 100644 index 000000000000..46eba74a7a58 --- /dev/null +++ b/tests/components/irm_kmi/test_config_flow.py @@ -0,0 +1,154 @@ +"""Tests for the IRM KMI config flow.""" + +from unittest.mock import MagicMock + +from homeassistant.components.irm_kmi.const import CONF_LANGUAGE_OVERRIDE, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_LOCATION, + CONF_UNIQUE_ID, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_full_user_flow( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + mock_get_forecast_in_benelux: MagicMock, +) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.123, ATTR_LONGITUDE: 4.456}}, + ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "Brussels" + assert result.get("data") == { + CONF_LOCATION: {ATTR_LATITUDE: 50.123, ATTR_LONGITUDE: 4.456}, + CONF_UNIQUE_ID: "brussels be", + } + + +async def test_user_flow_home( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + mock_get_forecast_in_benelux: MagicMock, +) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.123, ATTR_LONGITUDE: 4.456}}, + ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "Brussels" + + +async def test_config_flow_location_out_benelux( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + mock_get_forecast_out_benelux_then_in_belgium: MagicMock, +) -> None: + """Test configuration flow with a zone outside of Benelux.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: {ATTR_LATITUDE: 0.123, ATTR_LONGITUDE: 0.456}}, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + assert CONF_LOCATION in result.get("errors") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.123, ATTR_LONGITUDE: 4.456}}, + ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + + +async def test_config_flow_with_api_error( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + mock_get_forecast_api_error: MagicMock, +) -> None: + """Test when API returns an error during the configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.123, ATTR_LONGITUDE: 4.456}}, + ) + + assert result.get("type") is FlowResultType.ABORT + + +async def test_setup_twice_same_location( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + mock_get_forecast_in_benelux: MagicMock, +) -> None: + """Test when the user tries to set up the weather twice for the same location.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.5, ATTR_LONGITUDE: 4.6}}, + ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + + # Set up a second time + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.5, ATTR_LONGITUDE: 4.6}}, + ) + assert result.get("type") is FlowResultType.ABORT + + +async def test_option_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test when the user changes options with the option flow.""" + mock_config_entry.add_to_hass(hass) + + assert not mock_config_entry.options + + result = await hass.config_entries.options.async_init( + mock_config_entry.entry_id, data=None + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_LANGUAGE_OVERRIDE: "none"} diff --git a/tests/components/irm_kmi/test_init.py b/tests/components/irm_kmi/test_init.py new file mode 100644 index 000000000000..4fa310ab81a8 --- /dev/null +++ b/tests/components/irm_kmi/test_init.py @@ -0,0 +1,43 @@ +"""Tests for the IRM KMI integration.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.irm_kmi.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_irm_kmi_api: AsyncMock, +) -> None: + """Test the IRM KMI configuration entry loading/unloading.""" + 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 + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_exception_irm_kmi_api: AsyncMock, +) -> None: + """Test the IRM KMI configuration entry not ready.""" + 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_exception_irm_kmi_api.refresh_forecasts_coord.call_count == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/irm_kmi/test_weather.py b/tests/components/irm_kmi/test_weather.py new file mode 100644 index 000000000000..c02a7171c5dd --- /dev/null +++ b/tests/components/irm_kmi/test_weather.py @@ -0,0 +1,99 @@ +"""Test for the weather entity of the IRM KMI integration.""" + +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.weather import ( + DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.freeze_time("2023-12-28T15:30:00+01:00") +async def test_weather_nl( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_irm_kmi_api_nl: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test weather with forecast from the Netherland.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "forecast_type", + ["daily", "hourly"], +) +async def test_forecast_service( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_irm_kmi_api_nl: AsyncMock, + mock_config_entry: MockConfigEntry, + forecast_type: str, +) -> None: + """Test multiple forecast.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_ENTITY_ID: "weather.home", + "type": forecast_type, + }, + blocking=True, + return_response=True, + ) + assert response == snapshot + + +@pytest.mark.freeze_time("2024-01-21T14:15:00+01:00") +@pytest.mark.parametrize( + "forecast_type", + ["daily", "hourly"], +) +async def test_weather_higher_temp_at_night( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_irm_kmi_api_high_low_temp: AsyncMock, + forecast_type: str, +) -> None: + """Test that the templow is always lower than temperature, even when API returns the opposite.""" + # Test case for https://github.com/jdejaegh/irm-kmi-ha/issues/8 + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_ENTITY_ID: "weather.home", + "type": forecast_type, + }, + blocking=True, + return_response=True, + ) + for forecast in response["weather.home"]["forecast"]: + assert ( + forecast.get("native_temperature") is None + or forecast.get("native_templow") is None + or forecast["native_temperature"] >= forecast["native_templow"] + ) From b7db87bd3d66ccf3a377f5a676a5a82efaaaa419 Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Mon, 22 Sep 2025 15:07:49 +0200 Subject: [PATCH 017/189] Update regex for core logs path to include latest logs (#152747) --- homeassistant/components/hassio/http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 2b34a48149b9..60417a3dd652 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -70,7 +70,7 @@ PATHS_ADMIN = re.compile( r"|backups/new/upload" r"|audio/logs(/follow|/boots/-?\d+(/follow)?)?" r"|cli/logs(/follow|/boots/-?\d+(/follow)?)?" - r"|core/logs(/follow|/boots/-?\d+(/follow)?)?" + r"|core/logs(/latest|/follow|/boots/-?\d+(/follow)?)?" r"|dns/logs(/follow|/boots/-?\d+(/follow)?)?" r"|host/logs(/follow|/boots(/-?\d+(/follow)?)?)?" r"|multicast/logs(/follow|/boots/-?\d+(/follow)?)?" From 4b6dd0eb8ffa14839a800bd0120c4e558bfec613 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Mon, 22 Sep 2025 15:01:09 +0100 Subject: [PATCH 018/189] Add optional language to Mastodon post action (#151072) --- homeassistant/components/mastodon/const.py | 1 + homeassistant/components/mastodon/services.py | 4 + .../components/mastodon/services.yaml | 203 ++++++++++++++++++ .../components/mastodon/strings.json | 4 + tests/components/mastodon/test_services.py | 19 ++ 5 files changed, 231 insertions(+) diff --git a/homeassistant/components/mastodon/const.py b/homeassistant/components/mastodon/const.py index 8a77eebcf7a3..9c46f07029ba 100644 --- a/homeassistant/components/mastodon/const.py +++ b/homeassistant/components/mastodon/const.py @@ -18,3 +18,4 @@ ATTR_CONTENT_WARNING = "content_warning" ATTR_MEDIA_WARNING = "media_warning" ATTR_MEDIA = "media" ATTR_MEDIA_DESCRIPTION = "media_description" +ATTR_LANGUAGE = "language" diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py index 0815fee34ecd..c5347079a5f2 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -15,6 +15,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from .const import ( ATTR_CONTENT_WARNING, + ATTR_LANGUAGE, ATTR_MEDIA, ATTR_MEDIA_DESCRIPTION, ATTR_MEDIA_WARNING, @@ -42,6 +43,7 @@ SERVICE_POST_SCHEMA = vol.Schema( vol.Required(ATTR_STATUS): str, vol.Optional(ATTR_VISIBILITY): vol.In([x.lower() for x in StatusVisibility]), vol.Optional(ATTR_CONTENT_WARNING): str, + vol.Optional(ATTR_LANGUAGE): str, vol.Optional(ATTR_MEDIA): str, vol.Optional(ATTR_MEDIA_DESCRIPTION): str, vol.Optional(ATTR_MEDIA_WARNING): bool, @@ -82,6 +84,7 @@ def setup_services(hass: HomeAssistant) -> None: else None ) spoiler_text: str | None = call.data.get(ATTR_CONTENT_WARNING) + language: str | None = call.data.get(ATTR_LANGUAGE) media_path: str | None = call.data.get(ATTR_MEDIA) media_description: str | None = call.data.get(ATTR_MEDIA_DESCRIPTION) media_warning: str | None = call.data.get(ATTR_MEDIA_WARNING) @@ -93,6 +96,7 @@ def setup_services(hass: HomeAssistant) -> None: status=status, visibility=visibility, spoiler_text=spoiler_text, + language=language, media_path=media_path, media_description=media_description, sensitive=media_warning, diff --git a/homeassistant/components/mastodon/services.yaml b/homeassistant/components/mastodon/services.yaml index 206dc36c1a21..9db51f783b23 100644 --- a/homeassistant/components/mastodon/services.yaml +++ b/homeassistant/components/mastodon/services.yaml @@ -21,6 +21,209 @@ post: content_warning: selector: text: + language: + required: false + selector: + language: + languages: + - "aa" + - "ab" + - "ae" + - "af" + - "ak" + - "am" + - "an" + - "ar" + - "as" + - "ast" + - "av" + - "ay" + - "az" + - "ba" + - "be" + - "bg" + - "bi" + - "bm" + - "bn" + - "bo" + - "br" + - "bs" + - "ca" + - "ce" + - "ch" + - "chr" + - "ckb" + - "cnr" + - "co" + - "cr" + - "cs" + - "cu" + - "cv" + - "cy" + - "da" + - "de" + - "dv" + - "dz" + - "ee" + - "el" + - "en" + - "eo" + - "es" + - "et" + - "eu" + - "fa" + - "ff" + - "fi" + - "fj" + - "fo" # codespell:ignore fo + - "fr" + - "fy" + - "ga" + - "gd" + - "gl" + - "gu" + - "gv" + - "ha" + - "he" + - "hi" + - "ho" + - "hr" + - "ht" + - "hu" + - "hy" + - "hz" + - "ia" + - "id" + - "ie" + - "ig" + - "ii" + - "ik" + - "io" + - "is" + - "it" + - "iu" + - "ja" + - "jbo" + - "jv" + - "ka" + - "kab" + - "kg" + - "ki" + - "kj" + - "kk" + - "kl" + - "km" + - "kn" + - "ko" + - "kr" + - "ks" + - "ku" + - "kv" + - "kw" + - "ky" + - "la" + - "lb" + - "lfn" + - "lg" + - "li" + - "ln" + - "lo" + - "lt" + - "lu" + - "lv" + - "mg" + - "mh" + - "mi" + - "mk" + - "ml" + - "mn" + - "mr" + - "ms" + - "mt" + - "my" + - "na" + - "nb" + - "nd" # codespell:ignore nd + - "ne" + - "ng" + - "nl" + - "nn" + - "no" + - "nr" + - "nv" + - "ny" + - "oc" + - "oj" + - "om" + - "or" + - "os" + - "pa" + - "pi" + - "pl" + - "ps" + - "pt" + - "qu" + - "rm" + - "rn" + - "ro" + - "ru" + - "rw" + - "sa" + - "sc" + - "sco" + - "sd" + - "se" + - "sg" + - "si" + - "sk" + - "sl" + - "sma" + - "smj" + - "sn" + - "so" + - "sq" + - "sr" + - "ss" + - "st" + - "su" + - "sv" + - "sw" + - "szl" + - "ta" + - "te" # codespell:ignore te + - "tg" + - "th" + - "ti" + - "tk" + - "tl" + - "tn" + - "to" + - "tok" + - "tr" + - "ts" + - "tt" + - "tw" + - "ty" + - "ug" + - "uk" + - "ur" + - "uz" + - "ve" + - "vi" + - "vo" + - "wa" + - "wo" + - "xal" + - "xh" + - "yi" + - "yo" + - "za" + - "zgh" + - "zh" + - "zh-CN" + - "zh-HK" + - "zh-TW" + - "zu" media: selector: text: diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index c37f9b2e9416..5b8ce59fbd71 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -79,6 +79,10 @@ "name": "Content warning", "description": "A content warning will be shown before the status text is shown (default: no content warning)." }, + "language": { + "name": "Language", + "description": "The language of the post (default: Mastodon account preference)." + }, "media": { "name": "Media", "description": "Attach an image or video to the post." diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py index b08f886422fd..7902db010ca9 100644 --- a/tests/components/mastodon/test_services.py +++ b/tests/components/mastodon/test_services.py @@ -7,6 +7,7 @@ import pytest from homeassistant.components.mastodon.const import ( ATTR_CONTENT_WARNING, + ATTR_LANGUAGE, ATTR_MEDIA, ATTR_MEDIA_DESCRIPTION, ATTR_STATUS, @@ -34,6 +35,7 @@ from tests.common import MockConfigEntry "status": "test toot", "spoiler_text": None, "visibility": None, + "language": None, "media_ids": None, "sensitive": None, }, @@ -44,6 +46,7 @@ from tests.common import MockConfigEntry "status": "test toot", "spoiler_text": None, "visibility": "private", + "language": None, "media_ids": None, "sensitive": None, }, @@ -58,6 +61,7 @@ from tests.common import MockConfigEntry "status": "test toot", "spoiler_text": "Spoiler", "visibility": "private", + "language": None, "media_ids": None, "sensitive": None, }, @@ -66,12 +70,14 @@ from tests.common import MockConfigEntry { ATTR_STATUS: "test toot", ATTR_CONTENT_WARNING: "Spoiler", + ATTR_LANGUAGE: "nl", ATTR_MEDIA: "/image.jpg", }, { "status": "test toot", "spoiler_text": "Spoiler", "visibility": None, + "language": "nl", "media_ids": "1", "sensitive": None, }, @@ -80,6 +86,7 @@ from tests.common import MockConfigEntry { ATTR_STATUS: "test toot", ATTR_CONTENT_WARNING: "Spoiler", + ATTR_LANGUAGE: "en", ATTR_MEDIA: "/image.jpg", ATTR_MEDIA_DESCRIPTION: "A test image", }, @@ -87,10 +94,22 @@ from tests.common import MockConfigEntry "status": "test toot", "spoiler_text": "Spoiler", "visibility": None, + "language": "en", "media_ids": "1", "sensitive": None, }, ), + ( + {ATTR_STATUS: "test toot", ATTR_LANGUAGE: "invalid-lang"}, + { + "status": "test toot", + "language": "invalid-lang", + "spoiler_text": None, + "visibility": None, + "media_ids": None, + "sensitive": None, + }, + ), ], ) async def test_service_post( From 018d59a892823ee0548cb1db6bdcf0f02e972484 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:08:07 +0200 Subject: [PATCH 019/189] Drop hass argument from service extraction helpers (#152738) --- homeassistant/components/amcrest/services.py | 2 +- homeassistant/components/fritz/services.py | 7 ++--- .../components/google_mail/services.py | 2 +- .../components/homeassistant/__init__.py | 2 +- .../components/homeassistant/scene.py | 2 +- homeassistant/components/miele/services.py | 7 ++--- homeassistant/components/recorder/services.py | 10 +++---- homeassistant/components/sonos/services.py | 2 +- homeassistant/helpers/entity_component.py | 2 +- homeassistant/helpers/entity_platform.py | 2 +- homeassistant/helpers/service.py | 27 +++++++++---------- 11 files changed, 33 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/amcrest/services.py b/homeassistant/components/amcrest/services.py index 084761c4978f..6b4ca8ade535 100644 --- a/homeassistant/components/amcrest/services.py +++ b/homeassistant/components/amcrest/services.py @@ -41,7 +41,7 @@ def async_setup_services(hass: HomeAssistant) -> None: if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_NONE: return [] - call_ids = await async_extract_entity_ids(hass, call) + call_ids = await async_extract_entity_ids(call) entity_ids = [] for entity_id in hass.data[DATA_AMCREST][CAMERAS]: if entity_id not in call_ids: diff --git a/homeassistant/components/fritz/services.py b/homeassistant/components/fritz/services.py index bba80eadf984..43d10ee7f0aa 100644 --- a/homeassistant/components/fritz/services.py +++ b/homeassistant/components/fritz/services.py @@ -31,11 +31,12 @@ SERVICE_SCHEMA_SET_GUEST_WIFI_PW = vol.Schema( async def _async_set_guest_wifi_password(service_call: ServiceCall) -> None: """Call Fritz set guest wifi password service.""" - hass = service_call.hass - target_entry_ids = await async_extract_config_entry_ids(hass, service_call) + target_entry_ids = await async_extract_config_entry_ids(service_call) target_entries: list[FritzConfigEntry] = [ loaded_entry - for loaded_entry in hass.config_entries.async_loaded_entries(DOMAIN) + for loaded_entry in service_call.hass.config_entries.async_loaded_entries( + DOMAIN + ) if loaded_entry.entry_id in target_entry_ids ] diff --git a/homeassistant/components/google_mail/services.py b/homeassistant/components/google_mail/services.py index 129e04590d93..d8287ea35a17 100644 --- a/homeassistant/components/google_mail/services.py +++ b/homeassistant/components/google_mail/services.py @@ -51,7 +51,7 @@ async def _extract_gmail_config_entries( ) -> list[GoogleMailConfigEntry]: return [ entry - for entry_id in await async_extract_config_entry_ids(call.hass, call) + for entry_id in await async_extract_config_entry_ids(call) if (entry := call.hass.config_entries.async_get_entry(entry_id)) and entry.domain == DOMAIN ] diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 32fe690f0f19..d0892df399d3 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -339,7 +339,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: reload_entries: set[str] = set() if ATTR_ENTRY_ID in call.data: reload_entries.add(call.data[ATTR_ENTRY_ID]) - reload_entries.update(await async_extract_config_entry_ids(hass, call)) + reload_entries.update(await async_extract_config_entry_ids(call)) if not reload_entries: raise ValueError("There were no matching config entries to reload") await asyncio.gather( diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py index aec9b9cd06b6..33ae659f0f6d 100644 --- a/homeassistant/components/homeassistant/scene.py +++ b/homeassistant/components/homeassistant/scene.py @@ -272,7 +272,7 @@ async def async_setup_platform( async def delete_service(call: ServiceCall) -> None: """Delete a dynamically created scene.""" - entity_ids = await async_extract_entity_ids(hass, call) + entity_ids = await async_extract_entity_ids(call) for entity_id in entity_ids: scene = platform.entities.get(entity_id) diff --git a/homeassistant/components/miele/services.py b/homeassistant/components/miele/services.py index 517b489173d3..da8ee861f46d 100644 --- a/homeassistant/components/miele/services.py +++ b/homeassistant/components/miele/services.py @@ -58,11 +58,12 @@ _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_entry_ids = await async_extract_config_entry_ids(service_call) target_entries: list[MieleConfigEntry] = [ loaded_entry - for loaded_entry in hass.config_entries.async_loaded_entries(DOMAIN) + for loaded_entry in service_call.hass.config_entries.async_loaded_entries( + DOMAIN + ) if loaded_entry.entry_id in target_entry_ids ] if not target_entries: diff --git a/homeassistant/components/recorder/services.py b/homeassistant/components/recorder/services.py index ca92a2131d87..4e38d1f0a4d0 100644 --- a/homeassistant/components/recorder/services.py +++ b/homeassistant/components/recorder/services.py @@ -89,8 +89,7 @@ SERVICE_GET_STATISTICS_SCHEMA = vol.Schema( async def _async_handle_purge_service(service: ServiceCall) -> None: """Handle calls to the purge service.""" - hass = service.hass - instance = hass.data[DATA_INSTANCE] + instance = service.hass.data[DATA_INSTANCE] kwargs = service.data keep_days = kwargs.get(ATTR_KEEP_DAYS, instance.keep_days) repack = cast(bool, kwargs[ATTR_REPACK]) @@ -101,14 +100,15 @@ async def _async_handle_purge_service(service: ServiceCall) -> None: async def _async_handle_purge_entities_service(service: ServiceCall) -> None: """Handle calls to the purge entities service.""" - hass = service.hass - entity_ids = await async_extract_entity_ids(hass, service) + entity_ids = await async_extract_entity_ids(service) domains = service.data.get(ATTR_DOMAINS, []) keep_days = service.data.get(ATTR_KEEP_DAYS, 0) entity_globs = service.data.get(ATTR_ENTITY_GLOBS, []) entity_filter = generate_filter(domains, list(entity_ids), [], [], entity_globs) purge_before = dt_util.utcnow() - timedelta(days=keep_days) - hass.data[DATA_INSTANCE].queue_task(PurgeEntitiesTask(entity_filter, purge_before)) + service.hass.data[DATA_INSTANCE].queue_task( + PurgeEntitiesTask(entity_filter, purge_before) + ) async def _async_handle_enable_service(service: ServiceCall) -> None: diff --git a/homeassistant/components/sonos/services.py b/homeassistant/components/sonos/services.py index e2ec1bffdce0..883835a7c866 100644 --- a/homeassistant/components/sonos/services.py +++ b/homeassistant/components/sonos/services.py @@ -43,7 +43,7 @@ def async_setup_services(hass: HomeAssistant) -> None: ) entities = await service.async_extract_entities( - hass, platform_entities.values(), service_call + platform_entities.values(), service_call ) if not entities: diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index c7c602d088b8..2baeb31bdc80 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -240,7 +240,7 @@ class EntityComponent[_EntityT: entity.Entity = entity.Entity]: This method must be run in the event loop. """ return await service.async_extract_entities( - self.hass, self.entities, service_call, expand_group + self.entities, service_call, expand_group ) @callback diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index bf089dae765c..2587a197005b 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -1068,7 +1068,7 @@ class EntityPlatform: This method must be run in the event loop. """ return await service.async_extract_entities( - self.hass, self.entities.values(), service_call, expand_group + self.entities.values(), service_call, expand_group ) @callback diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index c5379f607f6f..aeba4b28cce3 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -379,22 +379,21 @@ def async_prepare_call_from_config( } -@bind_hass +@deprecated_hass_argument(breaks_in_ha_version="2026.10") def extract_entity_ids( - hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True + service_call: ServiceCall, expand_group: bool = True ) -> set[str]: """Extract a list of entity ids from a service call. Will convert group entity ids to the entity ids it represents. """ return asyncio.run_coroutine_threadsafe( - async_extract_entity_ids(hass, service_call, expand_group), hass.loop + async_extract_entity_ids(service_call, expand_group), service_call.hass.loop ).result() -@bind_hass +@deprecated_hass_argument(breaks_in_ha_version="2026.10") async def async_extract_entities[_EntityT: Entity]( - hass: HomeAssistant, entities: Iterable[_EntityT], service_call: ServiceCall, expand_group: bool = True, @@ -410,7 +409,7 @@ async def async_extract_entities[_EntityT: Entity]( selector_data = target_helpers.TargetSelectorData(service_call.data) referenced = target_helpers.async_extract_referenced_entity_ids( - hass, selector_data, expand_group + service_call.hass, selector_data, expand_group ) combined = referenced.referenced | referenced.indirectly_referenced @@ -432,9 +431,9 @@ async def async_extract_entities[_EntityT: Entity]( return found -@bind_hass +@deprecated_hass_argument(breaks_in_ha_version="2026.10") async def async_extract_entity_ids( - hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True + service_call: ServiceCall, expand_group: bool = True ) -> set[str]: """Extract a set of entity ids from a service call. @@ -442,7 +441,7 @@ async def async_extract_entity_ids( """ selector_data = target_helpers.TargetSelectorData(service_call.data) referenced = target_helpers.async_extract_referenced_entity_ids( - hass, selector_data, expand_group + service_call.hass, selector_data, expand_group ) return referenced.referenced | referenced.indirectly_referenced @@ -463,17 +462,17 @@ def async_extract_referenced_entity_ids( return SelectedEntities(**dataclasses.asdict(selected)) -@bind_hass +@deprecated_hass_argument(breaks_in_ha_version="2026.10") async def async_extract_config_entry_ids( - hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True + service_call: ServiceCall, expand_group: bool = True ) -> set[str]: """Extract referenced config entry ids from a service call.""" selector_data = target_helpers.TargetSelectorData(service_call.data) referenced = target_helpers.async_extract_referenced_entity_ids( - hass, selector_data, expand_group + service_call.hass, selector_data, expand_group ) - ent_reg = entity_registry.async_get(hass) - dev_reg = device_registry.async_get(hass) + ent_reg = entity_registry.async_get(service_call.hass) + dev_reg = device_registry.async_get(service_call.hass) config_entry_ids: set[str] = set() # Some devices may have no entities From fdbff767336b7ae17a5322c8fb8facd911df8f69 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:16:47 +0200 Subject: [PATCH 020/189] Add collapse checklist field to Habitica create/update task actions (#150988) --- homeassistant/components/habitica/const.py | 1 + homeassistant/components/habitica/services.py | 11 ++++++++ .../components/habitica/services.yaml | 11 ++++++++ .../components/habitica/strings.json | 26 +++++++++++++++++- tests/components/habitica/test_services.py | 27 +++++++++++++++++++ 5 files changed, 75 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index d7cede1db030..a32179889cfc 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -39,6 +39,7 @@ ATTR_ADD_CHECKLIST_ITEM = "add_checklist_item" ATTR_REMOVE_CHECKLIST_ITEM = "remove_checklist_item" ATTR_SCORE_CHECKLIST_ITEM = "score_checklist_item" ATTR_UNSCORE_CHECKLIST_ITEM = "unscore_checklist_item" +ATTR_COLLAPSE_CHECKLIST = "collapse_checklist" ATTR_REMINDER = "reminder" ATTR_REMOVE_REMINDER = "remove_reminder" ATTR_CLEAR_REMINDER = "clear_reminder" diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 38833f269322..1c677f18a588 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -47,6 +47,7 @@ from .const import ( ATTR_ALIAS, ATTR_CLEAR_DATE, ATTR_CLEAR_REMINDER, + ATTR_COLLAPSE_CHECKLIST, ATTR_CONFIG_ENTRY, ATTR_COST, ATTR_COUNTER_DOWN, @@ -130,6 +131,11 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema( } ) +COLLAPSE_CHECKLIST_MAP = { + "collapsed": True, + "expanded": False, +} + BASE_TASK_SCHEMA = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), @@ -160,6 +166,7 @@ BASE_TASK_SCHEMA = vol.Schema( vol.Optional(ATTR_REMOVE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]), vol.Optional(ATTR_SCORE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]), vol.Optional(ATTR_UNSCORE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_COLLAPSE_CHECKLIST): vol.In(COLLAPSE_CHECKLIST_MAP), vol.Optional(ATTR_START_DATE): cv.date, vol.Optional(ATTR_INTERVAL): vol.All(int, vol.Range(0)), vol.Optional(ATTR_REPEAT): vol.All(cv.ensure_list, [vol.In(WEEK_DAYS)]), @@ -223,6 +230,7 @@ ITEMID_MAP = { "shiny_seed": Skill.SHINY_SEED, } + SERVICE_TASK_TYPE_MAP = { SERVICE_UPDATE_REWARD: TaskType.REWARD, SERVICE_CREATE_REWARD: TaskType.REWARD, @@ -714,6 +722,9 @@ async def _create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa: ): data["checklist"] = checklist + if collapse_checklist := call.data.get(ATTR_COLLAPSE_CHECKLIST): + data["collapseChecklist"] = COLLAPSE_CHECKLIST_MAP[collapse_checklist] + reminders = current_task.reminders if current_task else [] if add_reminders := call.data.get(ATTR_REMINDER): diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index e7f4b4207b04..2752927ac0da 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -275,6 +275,15 @@ update_todo: selector: text: multiple: true + collapse_checklist: &collapse_checklist + required: false + selector: + select: + options: + - collapsed + - expanded + mode: list + translation_key: collapse_checklist priority: *priority duedate_options: collapsed: true @@ -318,6 +327,7 @@ create_todo: name: *name notes: *notes add_checklist_item: *add_checklist_item + collapse_checklist: *collapse_checklist priority: *priority date: *due_date reminder: *reminder @@ -419,6 +429,7 @@ create_daily: name: *name notes: *notes add_checklist_item: *add_checklist_item + collapse_checklist: *collapse_checklist priority: *priority start_date: *start_date frequency: *frequency_daily diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 3ea0a29ec5a6..335eacc05e9b 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -66,7 +66,9 @@ "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.", - "quest_name": "Quest" + "quest_name": "Quest", + "collapse_checklist_name": "Collapse/expand checklist", + "collapse_checklist_description": "Whether the checklist of a task is displayed as collapsed or expanded in Habitica." }, "config": { "abort": { @@ -1006,6 +1008,10 @@ "unscore_checklist_item": { "name": "[%key:component::habitica::common::unscore_checklist_item_name%]", "description": "[%key:component::habitica::common::unscore_checklist_item_description%]" + }, + "collapse_checklist": { + "name": "[%key:component::habitica::common::collapse_checklist_name%]", + "description": "[%key:component::habitica::common::collapse_checklist_description%]" } }, "sections": { @@ -1070,6 +1076,10 @@ "add_checklist_item": { "name": "[%key:component::habitica::common::checklist_options_name%]", "description": "[%key:component::habitica::common::add_checklist_item_description%]" + }, + "collapse_checklist": { + "name": "[%key:component::habitica::common::collapse_checklist_name%]", + "description": "[%key:component::habitica::common::collapse_checklist_description%]" } }, "sections": { @@ -1151,6 +1161,10 @@ "name": "[%key:component::habitica::common::unscore_checklist_item_name%]", "description": "[%key:component::habitica::common::unscore_checklist_item_description%]" }, + "collapse_checklist": { + "name": "[%key:component::habitica::common::collapse_checklist_name%]", + "description": "[%key:component::habitica::common::collapse_checklist_description%]" + }, "streak": { "name": "Adjust streak", "description": "Adjust or reset the streak counter of the daily." @@ -1247,6 +1261,10 @@ "name": "[%key:component::habitica::common::checklist_options_name%]", "description": "[%key:component::habitica::common::add_checklist_item_description%]" }, + "collapse_checklist": { + "name": "[%key:component::habitica::common::collapse_checklist_name%]", + "description": "[%key:component::habitica::common::collapse_checklist_description%]" + }, "reminder": { "name": "[%key:component::habitica::common::reminder_options_name%]", "description": "[%key:component::habitica::common::reminder_description%]" @@ -1325,6 +1343,12 @@ "day_of_month": "Day of the month", "day_of_week": "Day of the week" } + }, + "collapse_checklist": { + "options": { + "collapsed": "Collapsed", + "expanded": "Expanded" + } } } } diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 0e2a99ce215c..3692361942a6 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -28,6 +28,7 @@ from homeassistant.components.habitica.const import ( ATTR_ALIAS, ATTR_CLEAR_DATE, ATTR_CLEAR_REMINDER, + ATTR_COLLAPSE_CHECKLIST, ATTR_CONFIG_ENTRY, ATTR_COST, ATTR_COUNTER_DOWN, @@ -1498,6 +1499,18 @@ async def test_create_habit( }, Task(alias="ALIAS"), ), + ( + { + ATTR_COLLAPSE_CHECKLIST: "collapsed", + }, + Task(collapseChecklist=True), + ), + ( + { + ATTR_COLLAPSE_CHECKLIST: "expanded", + }, + Task(collapseChecklist=False), + ), ], ) @pytest.mark.usefixtures("mock_uuid4") @@ -1596,6 +1609,20 @@ async def test_update_todo( }, Task(type=TaskType.TODO, text="TITLE", alias="ALIAS"), ), + ( + { + ATTR_NAME: "TITLE", + ATTR_COLLAPSE_CHECKLIST: "collapsed", + }, + Task(type=TaskType.TODO, text="TITLE", collapseChecklist=True), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_COLLAPSE_CHECKLIST: "expanded", + }, + Task(type=TaskType.TODO, text="TITLE", collapseChecklist=False), + ), ], ) @pytest.mark.usefixtures("mock_uuid4") From b26b1df143450b7ad8c34e03985128996e379d76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 22 Sep 2025 15:19:31 +0100 Subject: [PATCH 021/189] Fix unitless converter missing valid units (#152665) --- homeassistant/util/unit_conversion.py | 2 ++ tests/components/sensor/test_recorder.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index be4372573f1b..b2938b249b87 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -742,6 +742,8 @@ class UnitlessRatioConverter(BaseUnitConverter): } VALID_UNITS = { None, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, } diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 50754d2244b3..695202b67c81 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -5201,7 +5201,7 @@ async def test_validate_statistics_unit_ignore_device_class( BATTERY_SENSOR_ATTRIBUTES, "%", None, - "%, ", + "%, , ppb, ppm", ), ], ) From 5a3570702de5b22cab3b7f99570a91b5fee76e11 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 22 Sep 2025 16:27:09 +0200 Subject: [PATCH 022/189] Add re-auth flow to AccuWeather integration (#152755) --- .../components/accuweather/config_flow.py | 46 +++++++++++++ .../components/accuweather/coordinator.py | 19 +++++- .../components/accuweather/strings.json | 17 ++++- .../accuweather/test_config_flow.py | 64 +++++++++++++++++++ tests/components/accuweather/test_init.py | 62 +++++++++++++++++- tests/components/accuweather/test_sensor.py | 3 +- 6 files changed, 205 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/accuweather/config_flow.py b/homeassistant/components/accuweather/config_flow.py index d16b9a1f77a1..00c5f9264569 100644 --- a/homeassistant/components/accuweather/config_flow.py +++ b/homeassistant/components/accuweather/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from asyncio import timeout +from collections.abc import Mapping from typing import Any from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError @@ -22,6 +23,8 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for AccuWeather.""" VERSION = 1 + _latitude: float | None = None + _longitude: float | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -74,3 +77,46 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle configuration by re-auth.""" + self._latitude = entry_data[CONF_LATITUDE] + self._longitude = entry_data[CONF_LONGITUDE] + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + + if user_input is not None: + websession = async_get_clientsession(self.hass) + try: + async with timeout(10): + accuweather = AccuWeather( + user_input[CONF_API_KEY], + websession, + latitude=self._latitude, + longitude=self._longitude, + ) + await accuweather.async_get_location() + except (ApiError, ClientConnectorError, TimeoutError, ClientError): + errors["base"] = "cannot_connect" + except InvalidApiKeyError: + errors["base"] = "invalid_api_key" + except RequestsExceededError: + errors["base"] = "requests_exceeded" + else: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data_updates=user_input + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, + ) diff --git a/homeassistant/components/accuweather/coordinator.py b/homeassistant/components/accuweather/coordinator.py index 7056c6e81fdb..3c4991d2c59f 100644 --- a/homeassistant/components/accuweather/coordinator.py +++ b/homeassistant/components/accuweather/coordinator.py @@ -15,6 +15,7 @@ from aiohttp.client_exceptions import ClientConnectorError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, @@ -30,7 +31,7 @@ from .const import ( UPDATE_INTERVAL_OBSERVATION, ) -EXCEPTIONS = (ApiError, ClientConnectorError, InvalidApiKeyError, RequestsExceededError) +EXCEPTIONS = (ApiError, ClientConnectorError, RequestsExceededError) _LOGGER = logging.getLogger(__name__) @@ -52,6 +53,8 @@ class AccuWeatherObservationDataUpdateCoordinator( ): """Class to manage fetching AccuWeather data API.""" + config_entry: AccuWeatherConfigEntry + def __init__( self, hass: HomeAssistant, @@ -87,6 +90,12 @@ class AccuWeatherObservationDataUpdateCoordinator( translation_key="current_conditions_update_error", translation_placeholders={"error": repr(error)}, ) from error + except InvalidApiKeyError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={"entry": self.config_entry.title}, + ) from err _LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining) @@ -98,6 +107,8 @@ class AccuWeatherForecastDataUpdateCoordinator( ): """Base class for AccuWeather forecast.""" + config_entry: AccuWeatherConfigEntry + def __init__( self, hass: HomeAssistant, @@ -137,6 +148,12 @@ class AccuWeatherForecastDataUpdateCoordinator( translation_key="forecast_update_error", translation_placeholders={"error": repr(error)}, ) from error + except InvalidApiKeyError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={"entry": self.config_entry.title}, + ) from err _LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining) return result diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index cbda5f8989f1..b46393acf78b 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -7,6 +7,17 @@ "api_key": "[%key:common::config_flow::data::api_key%]", "latitude": "[%key:common::config_flow::data::latitude%]", "longitude": "[%key:common::config_flow::data::longitude%]" + }, + "data_description": { + "api_key": "API key generated in the AccuWeather APIs portal." + } + }, + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::accuweather::config::step::user::data_description::api_key%]" } } }, @@ -19,7 +30,8 @@ "requests_exceeded": "The allowed number of requests to the AccuWeather API has been exceeded. You have to wait or change the API key." }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { @@ -239,6 +251,9 @@ } }, "exceptions": { + "auth_error": { + "message": "Authentication failed for {entry}, please update your API key" + }, "current_conditions_update_error": { "message": "An error occurred while retrieving weather current conditions data from the AccuWeather API: {error}" }, diff --git a/tests/components/accuweather/test_config_flow.py b/tests/components/accuweather/test_config_flow.py index ff1f31f01bc8..f17f4362aca3 100644 --- a/tests/components/accuweather/test_config_flow.py +++ b/tests/components/accuweather/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock from accuweather import ApiError, InvalidApiKeyError, RequestsExceededError +import pytest from homeassistant.components.accuweather.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -10,6 +11,8 @@ from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CON from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import init_integration + from tests.common import MockConfigEntry VALID_CONFIG = { @@ -117,3 +120,64 @@ async def test_create_entry( assert result["data"][CONF_LATITUDE] == 55.55 assert result["data"][CONF_LONGITUDE] == 122.12 assert result["data"][CONF_API_KEY] == "32-character-string-1234567890qw" + + +async def test_reauth_successful( + hass: HomeAssistant, mock_accuweather_client: AsyncMock +) -> None: + """Test starting a reauthentication flow.""" + mock_config_entry = await init_integration(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new_api_key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new_api_key" + + +@pytest.mark.parametrize( + ("exc", "base_error"), + [ + (ApiError("API Error"), "cannot_connect"), + (InvalidApiKeyError("Invalid API Key"), "invalid_api_key"), + (TimeoutError, "cannot_connect"), + (RequestsExceededError("Requests Exceeded"), "requests_exceeded"), + ], +) +async def test_reauth_errors( + hass: HomeAssistant, + exc: Exception, + base_error: str, + mock_accuweather_client: AsyncMock, +) -> None: + """Test reauthentication flow with errors.""" + mock_config_entry = await init_integration(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_accuweather_client.async_get_location.side_effect = exc + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new_api_key"}, + ) + + assert result["errors"] == {"base": base_error} + + mock_accuweather_client.async_get_location.side_effect = None + 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" + assert mock_config_entry.data[CONF_API_KEY] == "new_api_key" diff --git a/tests/components/accuweather/test_init.py b/tests/components/accuweather/test_init.py index f88cde88e7e8..f79ddaebb30b 100644 --- a/tests/components/accuweather/test_init.py +++ b/tests/components/accuweather/test_init.py @@ -1,8 +1,9 @@ """Test init of AccuWeather integration.""" +from datetime import timedelta from unittest.mock import AsyncMock -from accuweather import ApiError +from accuweather import ApiError, InvalidApiKeyError from freezegun.api import FrozenDateTimeFactory from homeassistant.components.accuweather.const import ( @@ -11,7 +12,7 @@ from homeassistant.components.accuweather.const import ( UPDATE_INTERVAL_OBSERVATION, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -118,3 +119,60 @@ async def test_remove_ozone_sensors( entry = entity_registry.async_get("sensor.home_ozone_0d") assert entry is None + + +async def test_auth_error( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_accuweather_client: AsyncMock, +) -> None: + """Test authentication error when polling data.""" + mock_accuweather_client.async_get_current_conditions.side_effect = ( + InvalidApiKeyError("Invalid API Key") + ) + + mock_config_entry = await init_integration(hass) + + assert mock_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") == mock_config_entry.entry_id + + +async def test_auth_error_whe_polling_data( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_accuweather_client: AsyncMock, +) -> None: + """Test authentication error when polling data.""" + mock_config_entry = await init_integration(hass) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_accuweather_client.async_get_current_conditions.side_effect = ( + InvalidApiKeyError("Invalid API Key") + ) + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_config_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") == mock_config_entry.entry_id diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py index 855c9f3e4d51..69035d639904 100644 --- a/tests/components/accuweather/test_sensor.py +++ b/tests/components/accuweather/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from accuweather import ApiError, InvalidApiKeyError, RequestsExceededError +from accuweather import ApiError, RequestsExceededError from aiohttp.client_exceptions import ClientConnectorError from freezegun.api import FrozenDateTimeFactory import pytest @@ -86,7 +86,6 @@ async def test_availability( ApiError("API Error"), ConnectionError, ClientConnectorError, - InvalidApiKeyError("Invalid API key"), RequestsExceededError("Requests exceeded"), ], ) From 6e93e480d15f825513803f4599fe9d5fc0834e57 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 22 Sep 2025 16:27:19 +0200 Subject: [PATCH 023/189] Use automatic reload options flow in integration (#152686) --- homeassistant/components/integration/__init__.py | 9 +-------- homeassistant/components/integration/config_flow.py | 1 + tests/components/integration/test_init.py | 1 + 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/integration/__init__.py b/homeassistant/components/integration/__init__.py index 82f44578aed5..b03baf32e91e 100644 --- a/homeassistant/components/integration/__init__.py +++ b/homeassistant/components/integration/__init__.py @@ -36,6 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, options={**entry.options, CONF_SOURCE_SENSOR: source_entity_id}, ) + hass.config_entries.async_schedule_reload(entry.entry_id) entry.async_on_unload( async_handle_source_entity_changes( @@ -51,7 +52,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,)) - entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) return True @@ -89,13 +89,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return True -async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update listener, called when the config entry options are changed.""" - # Remove device link for entry, the source device may have changed. - # The link will be recreated after load. - 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, (Platform.SENSOR,)) diff --git a/homeassistant/components/integration/config_flow.py b/homeassistant/components/integration/config_flow.py index 329abdbea875..370de8b80113 100644 --- a/homeassistant/components/integration/config_flow.py +++ b/homeassistant/components/integration/config_flow.py @@ -151,6 +151,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW + options_flow_reloads = True def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" diff --git a/tests/components/integration/test_init.py b/tests/components/integration/test_init.py index 50243551d370..b0d98011a17c 100644 --- a/tests/components/integration/test_init.py +++ b/tests/components/integration/test_init.py @@ -203,6 +203,7 @@ async def test_entry_changed(hass: HomeAssistant, platform) -> None: hass.config_entries.async_update_entry( config_entry, options={**config_entry.options, "source": "sensor.valid"} ) + hass.config_entries.async_schedule_reload(config_entry.entry_id) await hass.async_block_till_done() # Check that the device association has updated From d565fb3cb4bf0ddec2b52c0b04332e099ed4c706 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 22 Sep 2025 17:33:48 +0200 Subject: [PATCH 024/189] Bump mcp to 1.14.1 (#152737) --- homeassistant/components/mcp/manifest.json | 2 +- homeassistant/components/mcp_server/http.py | 19 +++++++++++-------- .../components/mcp_server/manifest.json | 2 +- homeassistant/components/mcp_server/server.py | 2 +- .../components/mcp_server/session.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 18 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/mcp/manifest.json b/homeassistant/components/mcp/manifest.json index 7ff64d29aa47..dfc180f70228 100644 --- a/homeassistant/components/mcp/manifest.json +++ b/homeassistant/components/mcp/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/mcp", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["mcp==1.5.0"] + "requirements": ["mcp==1.14.1"] } diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py index 07c8ff39f62f..76867b6c85db 100644 --- a/homeassistant/components/mcp_server/http.py +++ b/homeassistant/components/mcp_server/http.py @@ -22,6 +22,7 @@ from aiohttp_sse import sse_response import anyio from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from mcp import types +from mcp.shared.message import SessionMessage from homeassistant.components import conversation from homeassistant.components.http import KEY_HASS, HomeAssistantView @@ -98,12 +99,12 @@ class ModelContextProtocolSSEView(HomeAssistantView): server.create_initialization_options # Reads package for version info ) - read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception] - read_stream_writer: MemoryObjectSendStream[types.JSONRPCMessage | Exception] + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] + read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] read_stream_writer, read_stream = anyio.create_memory_object_stream(0) - write_stream: MemoryObjectSendStream[types.JSONRPCMessage] - write_stream_reader: MemoryObjectReceiveStream[types.JSONRPCMessage] + write_stream: MemoryObjectSendStream[SessionMessage] + write_stream_reader: MemoryObjectReceiveStream[SessionMessage] write_stream, write_stream_reader = anyio.create_memory_object_stream(0) async with ( @@ -116,10 +117,12 @@ class ModelContextProtocolSSEView(HomeAssistantView): async def sse_reader() -> None: """Forward MCP server responses to the client.""" - async for message in write_stream_reader: - _LOGGER.debug("Sending SSE message: %s", message) + async for session_message in write_stream_reader: + _LOGGER.debug("Sending SSE message: %s", session_message) await response.send( - message.model_dump_json(by_alias=True, exclude_none=True), + session_message.message.model_dump_json( + by_alias=True, exclude_none=True + ), event="message", ) @@ -163,5 +166,5 @@ class ModelContextProtocolMessagesView(HomeAssistantView): raise HTTPBadRequest(text="Could not parse message") from err _LOGGER.debug("Received client message: %s", message) - await session.read_stream_writer.send(message) + await session.read_stream_writer.send(SessionMessage(message)) return web.Response(status=200) diff --git a/homeassistant/components/mcp_server/manifest.json b/homeassistant/components/mcp_server/manifest.json index 452714f14cda..abc43ffffeb2 100644 --- a/homeassistant/components/mcp_server/manifest.json +++ b/homeassistant/components/mcp_server/manifest.json @@ -8,6 +8,6 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["mcp==1.5.0", "aiohttp_sse==2.2.0", "anyio==4.10.0"], + "requirements": ["mcp==1.14.1", "aiohttp_sse==2.2.0", "anyio==4.10.0"], "single_config_entry": true } diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py index 953fc1314daf..85bcd407fef9 100644 --- a/homeassistant/components/mcp_server/server.py +++ b/homeassistant/components/mcp_server/server.py @@ -96,7 +96,7 @@ async def create_server( llm_api = await get_api_instance() return [_format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools] - @server.call_tool() # type: ignore[no-untyped-call, misc] + @server.call_tool() # type: ignore[misc] async def call_tool(name: str, arguments: dict) -> Sequence[types.TextContent]: """Handle calling tools.""" llm_api = await get_api_instance() diff --git a/homeassistant/components/mcp_server/session.py b/homeassistant/components/mcp_server/session.py index 4c586fd32a0e..e4bfe25eaf52 100644 --- a/homeassistant/components/mcp_server/session.py +++ b/homeassistant/components/mcp_server/session.py @@ -11,7 +11,7 @@ from dataclasses import dataclass import logging from anyio.streams.memory import MemoryObjectSendStream -from mcp import types +from mcp.shared.message import SessionMessage from homeassistant.util import ulid as ulid_util @@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) class Session: """A session for the Model Context Protocol.""" - read_stream_writer: MemoryObjectSendStream[types.JSONRPCMessage | Exception] + read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] class SessionManager: diff --git a/requirements_all.txt b/requirements_all.txt index dccd226ac18c..980f42174d2b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1416,7 +1416,7 @@ mbddns==0.1.2 # homeassistant.components.mcp # homeassistant.components.mcp_server -mcp==1.5.0 +mcp==1.14.1 # homeassistant.components.minecraft_server mcstatus==12.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index be4803bd890c..d4cab9136ef5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1214,7 +1214,7 @@ mbddns==0.1.2 # homeassistant.components.mcp # homeassistant.components.mcp_server -mcp==1.5.0 +mcp==1.14.1 # homeassistant.components.minecraft_server mcstatus==12.0.1 From d9d42b3ad56bfc463794209f7e89625511fe5dba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Sep 2025 09:54:15 -0600 Subject: [PATCH 025/189] Pass timezone to aioesphomeapi to ensure HA timezone takes precedence (#152756) --- homeassistant/components/esphome/__init__.py | 1 + tests/components/esphome/conftest.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index f621c74642b3..cb1a3d10c97e 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -51,6 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> b client_info=CLIENT_INFO, zeroconf_instance=zeroconf_instance, noise_psk=noise_psk, + timezone=hass.config.time_zone, ) domain_data = DomainData.get(hass) diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index f9383d3b4f78..9fe709322af4 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -187,13 +187,15 @@ def mock_client(mock_device_info) -> Generator[APIClient]: zeroconf_instance: Zeroconf = None, noise_psk: str | None = None, expected_name: str | None = None, - ): + timezone: str | None = None, + ) -> None: """Fake the client constructor.""" mock_client.host = address mock_client.port = port mock_client.password = password mock_client.zeroconf_instance = zeroconf_instance mock_client.noise_psk = noise_psk + mock_client.timezone = timezone return mock_client mock_client.side_effect = mock_constructor From 9059e3dadcccb7792066fa868dceb0fc10570979 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Mon, 22 Sep 2025 18:41:44 +0200 Subject: [PATCH 026/189] Prepare Volvo integration for new platforms (#152042) --- homeassistant/components/volvo/__init__.py | 13 +- .../components/volvo/binary_sensor.py | 2 +- homeassistant/components/volvo/coordinator.py | 145 +++++++++++------- homeassistant/components/volvo/entity.py | 2 +- homeassistant/components/volvo/sensor.py | 2 +- 5 files changed, 103 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/volvo/__init__.py b/homeassistant/components/volvo/__init__.py index fa2c7530cac8..403dce7bfe61 100644 --- a/homeassistant/components/volvo/__init__.py +++ b/homeassistant/components/volvo/__init__.py @@ -24,8 +24,10 @@ from .api import VolvoAuth from .const import CONF_VIN, DOMAIN, PLATFORMS from .coordinator import ( VolvoConfigEntry, + VolvoContext, VolvoFastIntervalCoordinator, VolvoMediumIntervalCoordinator, + VolvoRuntimeData, VolvoSlowIntervalCoordinator, VolvoVerySlowIntervalCoordinator, ) @@ -36,21 +38,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> boo api = await _async_auth_and_create_api(hass, entry) vehicle = await _async_load_vehicle(api) + context = VolvoContext(api, vehicle) # Order is important! Faster intervals must come first. # Different interval coordinators are in place to keep the number # of requests under 5000 per day. This lets users use the same # API key for two vehicles (as the limit is 10000 per day). coordinators = ( - VolvoFastIntervalCoordinator(hass, entry, api, vehicle), - VolvoMediumIntervalCoordinator(hass, entry, api, vehicle), - VolvoSlowIntervalCoordinator(hass, entry, api, vehicle), - VolvoVerySlowIntervalCoordinator(hass, entry, api, vehicle), + VolvoFastIntervalCoordinator(hass, entry, context), + VolvoMediumIntervalCoordinator(hass, entry, context), + VolvoSlowIntervalCoordinator(hass, entry, context), + VolvoVerySlowIntervalCoordinator(hass, entry, context), ) await asyncio.gather(*(c.async_config_entry_first_refresh() for c in coordinators)) - entry.runtime_data = coordinators + entry.runtime_data = VolvoRuntimeData(coordinators) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/volvo/binary_sensor.py b/homeassistant/components/volvo/binary_sensor.py index 5edbcf081269..fe8783d93340 100644 --- a/homeassistant/components/volvo/binary_sensor.py +++ b/homeassistant/components/volvo/binary_sensor.py @@ -366,7 +366,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensors.""" - coordinators = entry.runtime_data + coordinators = entry.runtime_data.interval_coordinators async_add_entities( VolvoBinarySensor(coordinator, description) for coordinator in coordinators diff --git a/homeassistant/components/volvo/coordinator.py b/homeassistant/components/volvo/coordinator.py index 7dc8c47eccc0..cbb4915f4a0c 100644 --- a/homeassistant/components/volvo/coordinator.py +++ b/homeassistant/components/volvo/coordinator.py @@ -5,9 +5,10 @@ from __future__ import annotations from abc import abstractmethod import asyncio from collections.abc import Callable, Coroutine +from dataclasses import dataclass from datetime import timedelta import logging -from typing import Any, cast +from typing import Any, Generic, TypeVar, cast from volvocarsapi.api import VolvoCarsApi from volvocarsapi.models import ( @@ -34,7 +35,22 @@ FAST_INTERVAL = 1 _LOGGER = logging.getLogger(__name__) -type VolvoConfigEntry = ConfigEntry[tuple[VolvoBaseCoordinator, ...]] +@dataclass +class VolvoContext: + """Volvo context.""" + + api: VolvoCarsApi + vehicle: VolvoCarsVehicle + + +@dataclass +class VolvoRuntimeData: + """Volvo runtime data.""" + + interval_coordinators: tuple[VolvoBaseIntervalCoordinator, ...] + + +type VolvoConfigEntry = ConfigEntry[VolvoRuntimeData] type CoordinatorData = dict[str, VolvoCarsApiBaseModel | None] @@ -48,7 +64,10 @@ def _is_invalid_api_field(field: VolvoCarsApiBaseModel | None) -> bool: return False -class VolvoBaseCoordinator(DataUpdateCoordinator[CoordinatorData]): +T = TypeVar("T", bound=dict, default=dict[str, Any]) + + +class VolvoBaseCoordinator(DataUpdateCoordinator[T], Generic[T]): """Volvo base coordinator.""" config_entry: VolvoConfigEntry @@ -57,9 +76,8 @@ class VolvoBaseCoordinator(DataUpdateCoordinator[CoordinatorData]): self, hass: HomeAssistant, entry: VolvoConfigEntry, - api: VolvoCarsApi, - vehicle: VolvoCarsVehicle, - update_interval: timedelta, + context: VolvoContext, + update_interval: timedelta | None, name: str, ) -> None: """Initialize the coordinator.""" @@ -72,8 +90,34 @@ class VolvoBaseCoordinator(DataUpdateCoordinator[CoordinatorData]): update_interval=update_interval, ) - self.api = api - self.vehicle = vehicle + self.context = context + + 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 + + +class VolvoBaseIntervalCoordinator(VolvoBaseCoordinator[CoordinatorData]): + """Volvo base interval coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + entry: VolvoConfigEntry, + context: VolvoContext, + update_interval: timedelta, + name: str, + ) -> None: + """Initialize the coordinator.""" + + super().__init__( + hass, + entry, + context, + update_interval, + name, + ) self._api_calls: list[Callable[[], Coroutine[Any, Any, Any]]] = [] @@ -151,11 +195,6 @@ class VolvoBaseCoordinator(DataUpdateCoordinator[CoordinatorData]): 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, @@ -163,23 +202,21 @@ class VolvoBaseCoordinator(DataUpdateCoordinator[CoordinatorData]): raise NotImplementedError -class VolvoVerySlowIntervalCoordinator(VolvoBaseCoordinator): +class VolvoVerySlowIntervalCoordinator(VolvoBaseIntervalCoordinator): """Volvo coordinator with very slow update rate.""" def __init__( self, hass: HomeAssistant, entry: VolvoConfigEntry, - api: VolvoCarsApi, - vehicle: VolvoCarsVehicle, + context: VolvoContext, ) -> None: """Initialize the coordinator.""" super().__init__( hass, entry, - api, - vehicle, + context, timedelta(minutes=VERY_SLOW_INTERVAL), "Volvo very slow interval coordinator", ) @@ -187,47 +224,47 @@ class VolvoVerySlowIntervalCoordinator(VolvoBaseCoordinator): async def _async_determine_api_calls( self, ) -> list[Callable[[], Coroutine[Any, Any, Any]]]: + api = self.context.api + return [ - self.api.async_get_brakes_status, - self.api.async_get_diagnostics, - self.api.async_get_engine_warnings, - self.api.async_get_odometer, - self.api.async_get_statistics, - self.api.async_get_tyre_states, - self.api.async_get_warnings, + api.async_get_brakes_status, + api.async_get_diagnostics, + api.async_get_engine_warnings, + api.async_get_odometer, + api.async_get_statistics, + api.async_get_tyre_states, + api.async_get_warnings, ] async def _async_update_data(self) -> CoordinatorData: data = await super()._async_update_data() # Add static values - if self.vehicle.has_battery_engine(): + if self.context.vehicle.has_battery_engine(): data[DATA_BATTERY_CAPACITY] = VolvoCarsValue.from_dict( { - "value": self.vehicle.battery_capacity_kwh, + "value": self.context.vehicle.battery_capacity_kwh, } ) return data -class VolvoSlowIntervalCoordinator(VolvoBaseCoordinator): +class VolvoSlowIntervalCoordinator(VolvoBaseIntervalCoordinator): """Volvo coordinator with slow update rate.""" def __init__( self, hass: HomeAssistant, entry: VolvoConfigEntry, - api: VolvoCarsApi, - vehicle: VolvoCarsVehicle, + context: VolvoContext, ) -> None: """Initialize the coordinator.""" super().__init__( hass, entry, - api, - vehicle, + context, timedelta(minutes=SLOW_INTERVAL), "Volvo slow interval coordinator", ) @@ -235,32 +272,32 @@ class VolvoSlowIntervalCoordinator(VolvoBaseCoordinator): async def _async_determine_api_calls( self, ) -> list[Callable[[], Coroutine[Any, Any, Any]]]: - if self.vehicle.has_combustion_engine(): + api = self.context.api + + if self.context.vehicle.has_combustion_engine(): return [ - self.api.async_get_command_accessibility, - self.api.async_get_fuel_status, + api.async_get_command_accessibility, + api.async_get_fuel_status, ] - return [self.api.async_get_command_accessibility] + return [api.async_get_command_accessibility] -class VolvoMediumIntervalCoordinator(VolvoBaseCoordinator): +class VolvoMediumIntervalCoordinator(VolvoBaseIntervalCoordinator): """Volvo coordinator with medium update rate.""" def __init__( self, hass: HomeAssistant, entry: VolvoConfigEntry, - api: VolvoCarsApi, - vehicle: VolvoCarsVehicle, + context: VolvoContext, ) -> None: """Initialize the coordinator.""" super().__init__( hass, entry, - api, - vehicle, + context, timedelta(minutes=MEDIUM_INTERVAL), "Volvo medium interval coordinator", ) @@ -271,9 +308,11 @@ class VolvoMediumIntervalCoordinator(VolvoBaseCoordinator): self, ) -> list[Callable[[], Coroutine[Any, Any, Any]]]: api_calls: list[Any] = [] + api = self.context.api + vehicle = self.context.vehicle - if self.vehicle.has_battery_engine(): - capabilities = await self.api.async_get_energy_capabilities() + if vehicle.has_battery_engine(): + capabilities = await api.async_get_energy_capabilities() if capabilities.get("isSupported", False): @@ -288,8 +327,8 @@ class VolvoMediumIntervalCoordinator(VolvoBaseCoordinator): api_calls.append(self._async_get_energy_state) - if self.vehicle.has_combustion_engine(): - api_calls.append(self.api.async_get_engine_status) + if vehicle.has_combustion_engine(): + api_calls.append(api.async_get_engine_status) return api_calls @@ -304,7 +343,7 @@ class VolvoMediumIntervalCoordinator(VolvoBaseCoordinator): return field - energy_state = await self.api.async_get_energy_state() + energy_state = await self.context.api.async_get_energy_state() return { key: _mark_ok(value) @@ -313,23 +352,21 @@ class VolvoMediumIntervalCoordinator(VolvoBaseCoordinator): } -class VolvoFastIntervalCoordinator(VolvoBaseCoordinator): +class VolvoFastIntervalCoordinator(VolvoBaseIntervalCoordinator): """Volvo coordinator with fast update rate.""" def __init__( self, hass: HomeAssistant, entry: VolvoConfigEntry, - api: VolvoCarsApi, - vehicle: VolvoCarsVehicle, + context: VolvoContext, ) -> None: """Initialize the coordinator.""" super().__init__( hass, entry, - api, - vehicle, + context, timedelta(minutes=FAST_INTERVAL), "Volvo fast interval coordinator", ) @@ -337,7 +374,9 @@ class VolvoFastIntervalCoordinator(VolvoBaseCoordinator): async def _async_determine_api_calls( self, ) -> list[Callable[[], Coroutine[Any, Any, Any]]]: + api = self.context.api + return [ - self.api.async_get_doors_status, - self.api.async_get_window_states, + api.async_get_doors_status, + api.async_get_window_states, ] diff --git a/homeassistant/components/volvo/entity.py b/homeassistant/components/volvo/entity.py index f23bd714870f..a8960a5f68f8 100644 --- a/homeassistant/components/volvo/entity.py +++ b/homeassistant/components/volvo/entity.py @@ -54,7 +54,7 @@ class VolvoEntity(CoordinatorEntity[VolvoBaseCoordinator]): coordinator.config_entry.data[CONF_VIN], description.key ) - vehicle = coordinator.vehicle + vehicle = coordinator.context.vehicle model = ( f"{vehicle.description.model} ({vehicle.model_year})" if vehicle.fuel_type == "NONE" diff --git a/homeassistant/components/volvo/sensor.py b/homeassistant/components/volvo/sensor.py index 2d1274c17c06..13614ff28302 100644 --- a/homeassistant/components/volvo/sensor.py +++ b/homeassistant/components/volvo/sensor.py @@ -354,7 +354,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors.""" - coordinators = entry.runtime_data + coordinators = entry.runtime_data.interval_coordinators async_add_entities( VolvoSensor(coordinator, description) for coordinator in coordinators From 7b7265a6b0090cf221452324207607024108e20b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 22 Sep 2025 18:03:32 +0100 Subject: [PATCH 027/189] Revert "Add EZVIZ battery camera power status and online status sensor (#146822)" (#152767) --- homeassistant/components/ezviz/sensor.py | 46 ++++----------------- homeassistant/components/ezviz/strings.json | 12 ------ 2 files changed, 8 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py index ec631e8e5c15..c441b34b42dd 100644 --- a/homeassistant/components/ezviz/sensor.py +++ b/homeassistant/components/ezviz/sensor.py @@ -66,26 +66,6 @@ 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, - ), } @@ -96,26 +76,16 @@ async def async_setup_entry( ) -> None: """Set up EZVIZ sensors based on a config entry.""" coordinator = entry.runtime_data - entities: list[EzvizSensor] = [] - for camera, sensors in coordinator.data.items(): - entities.extend( + async_add_entities( + [ EzvizSensor(coordinator, camera, sensor) - 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) + for camera in coordinator.data + for sensor, value in coordinator.data[camera].items() + if sensor in SENSOR_TYPES + if value is not None + ] + ) class EzvizSensor(EzvizEntity, SensorEntity): diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index ad8f7114407c..b03a5dbc61a8 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -147,18 +147,6 @@ }, "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 4eaf6784afb98e2af6bb5221c4e86948a779f3cc Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Mon, 22 Sep 2025 20:34:31 +0200 Subject: [PATCH 028/189] Use satellite entity area in the default agent (#152762) --- .../components/conversation/default_agent.py | 66 ++++++++++++------- .../components/conversation/trigger.py | 16 ++++- .../conversation/test_default_agent.py | 18 ++--- 3 files changed, 65 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 68029190439c..059b378b9a83 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -153,8 +153,8 @@ class IntentCacheKey: language: str """Language of text.""" - device_id: str | None - """Device id from user input.""" + satellite_id: str | None + """Satellite id from user input.""" @dataclass(frozen=True) @@ -443,9 +443,15 @@ class DefaultAgent(ConversationEntity): } for entity in result.entities_list } - device_area = self._get_device_area(user_input.device_id) - if device_area: - slots["preferred_area_id"] = {"value": device_area.id} + + satellite_id = user_input.satellite_id + device_id = user_input.device_id + satellite_area, device_id = self._get_satellite_area_and_device( + satellite_id, device_id + ) + if satellite_area is not None: + slots["preferred_area_id"] = {"value": satellite_area.id} + async_conversation_trace_append( ConversationTraceEventType.TOOL_CALL, { @@ -467,8 +473,8 @@ class DefaultAgent(ConversationEntity): user_input.context, language, assistant=DOMAIN, - device_id=user_input.device_id, - satellite_id=user_input.satellite_id, + device_id=device_id, + satellite_id=satellite_id, conversation_agent_id=user_input.agent_id, ) except intent.MatchFailedError as match_error: @@ -534,7 +540,9 @@ class DefaultAgent(ConversationEntity): # Try cache first cache_key = IntentCacheKey( - text=user_input.text, language=language, device_id=user_input.device_id + text=user_input.text, + language=language, + satellite_id=user_input.satellite_id, ) cache_value = self._intent_cache.get(cache_key) if cache_value is not None: @@ -1304,28 +1312,40 @@ class DefaultAgent(ConversationEntity): self, user_input: ConversationInput ) -> dict[str, Any] | None: """Return intent recognition context for user input.""" - if not user_input.device_id: + satellite_area, _ = self._get_satellite_area_and_device( + user_input.satellite_id, user_input.device_id + ) + if satellite_area is None: return None - device_area = self._get_device_area(user_input.device_id) - if device_area is None: - return None + return {"area": {"value": satellite_area.name, "text": satellite_area.name}} - return {"area": {"value": device_area.name, "text": device_area.name}} + def _get_satellite_area_and_device( + self, satellite_id: str | None, device_id: str | None = None + ) -> tuple[ar.AreaEntry | None, str | None]: + """Return area entry and device id.""" + hass = self.hass - def _get_device_area(self, device_id: str | None) -> ar.AreaEntry | None: - """Return area object for given device identifier.""" - if device_id is None: - return None + area_id: str | None = None - devices = dr.async_get(self.hass) - device = devices.async_get(device_id) - if (device is None) or (device.area_id is None): - return None + if ( + satellite_id is not None + and (entity_entry := er.async_get(hass).async_get(satellite_id)) is not None + ): + area_id = entity_entry.area_id + device_id = entity_entry.device_id - areas = ar.async_get(self.hass) + if ( + area_id is None + and device_id is not None + and (device_entry := dr.async_get(hass).async_get(device_id)) is not None + ): + area_id = device_entry.area_id - return areas.async_get_area(device.area_id) + if area_id is None: + return None, device_id + + return ar.async_get(hass).async_get_area(area_id), device_id def _get_error_text( self, diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index 36f8b2246776..b6b1273f1ab9 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -15,7 +15,7 @@ import voluptuous as vol from homeassistant.const import CONF_COMMAND, CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.script import ScriptRunResult from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import UNDEFINED, ConfigType @@ -71,6 +71,8 @@ async def async_attach_trigger( trigger_data = trigger_info["trigger_data"] sentences = config.get(CONF_COMMAND, []) + ent_reg = er.async_get(hass) + job = HassJob(action) async def call_action( @@ -92,6 +94,14 @@ async def async_attach_trigger( for entity_name, entity in result.entities.items() } + satellite_id = user_input.satellite_id + device_id = user_input.device_id + if ( + satellite_id is not None + and (satellite_entry := ent_reg.async_get(satellite_id)) is not None + ): + device_id = satellite_entry.device_id + trigger_input: dict[str, Any] = { # Satisfy type checker **trigger_data, "platform": DOMAIN, @@ -100,8 +110,8 @@ async def async_attach_trigger( "slots": { # direct access to values entity_name: entity["value"] for entity_name, entity in details.items() }, - "device_id": user_input.device_id, - "satellite_id": user_input.satellite_id, + "device_id": device_id, + "satellite_id": satellite_id, "user_input": user_input.as_dict(), } diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 6dcb032c0d3d..69fbe3caf820 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -522,13 +522,13 @@ async def test_respond_intent(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("init_components") -async def test_device_area_context( +async def test_satellite_area_context( hass: HomeAssistant, area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test that including a device_id will target a specific area.""" + """Test that including a satellite will target a specific area.""" turn_on_calls = async_mock_service(hass, "light", "turn_on") turn_off_calls = async_mock_service(hass, "light", "turn_off") @@ -560,12 +560,12 @@ async def test_device_area_context( entry = MockConfigEntry() entry.add_to_hass(hass) - kitchen_satellite = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections=set(), - identifiers={("demo", "id-satellite-kitchen")}, + kitchen_satellite = entity_registry.async_get_or_create( + "assist_satellite", "demo", "kitchen" + ) + entity_registry.async_update_entity( + kitchen_satellite.entity_id, area_id=area_kitchen.id ) - device_registry.async_update_device(kitchen_satellite.id, area_id=area_kitchen.id) bedroom_satellite = device_registry.async_get_or_create( config_entry_id=entry.entry_id, @@ -581,7 +581,7 @@ async def test_device_area_context( None, Context(), None, - device_id=kitchen_satellite.id, + satellite_id=kitchen_satellite.entity_id, ) await hass.async_block_till_done() assert result.response.response_type == intent.IntentResponseType.ACTION_DONE @@ -605,7 +605,7 @@ async def test_device_area_context( None, Context(), None, - device_id=kitchen_satellite.id, + satellite_id=kitchen_satellite.entity_id, ) await hass.async_block_till_done() assert result.response.response_type == intent.IntentResponseType.ACTION_DONE From 1bb3c96fc150ac6b8ba2d3da4b4ed570667639b3 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Mon, 22 Sep 2025 21:26:26 +0200 Subject: [PATCH 029/189] Drop Windows compatibility code from systemmonitor integration (#152545) --- homeassistant/components/systemmonitor/util.py | 6 ------ tests/components/systemmonitor/conftest.py | 2 -- tests/components/systemmonitor/test_sensor.py | 13 ------------- tests/components/systemmonitor/test_util.py | 10 ++-------- 4 files changed, 2 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py index 2a4b889bdde8..dec0508bb646 100644 --- a/homeassistant/components/systemmonitor/util.py +++ b/homeassistant/components/systemmonitor/util.py @@ -21,12 +21,6 @@ def get_all_disk_mounts( """Return all disk mount points on system.""" disks: set[str] = set() for part in psutil_wrapper.psutil.disk_partitions(all=True): - if os.name == "nt": - if "cdrom" in part.opts or part.fstype == "": - # skip cd-rom drives with no disk in it; they may raise - # ENOENT, pop-up a Windows GUI error for a non-ready - # partition or just hang. - continue if part.fstype in SKIP_DISK_TYPES: # Ignore disks which are memory continue diff --git a/tests/components/systemmonitor/conftest.py b/tests/components/systemmonitor/conftest.py index 5f0a7a5c76da..a5aa15d8b0a5 100644 --- a/tests/components/systemmonitor/conftest.py +++ b/tests/components/systemmonitor/conftest.py @@ -176,7 +176,6 @@ def mock_psutil(mock_process: list[MockProcess]) -> Generator: mock_psutil.disk_partitions.return_value = [ sdiskpart("test", "/", "ext4", ""), sdiskpart("test2", "/media/share", "ext4", ""), - sdiskpart("test3", "/incorrect", "", ""), sdiskpart("hosts", "/etc/hosts", "bind", ""), sdiskpart("proc", "/proc/run", "proc", ""), ] @@ -197,7 +196,6 @@ def mock_os() -> Generator: patch("homeassistant.components.systemmonitor.coordinator.os") as mock_os, patch("homeassistant.components.systemmonitor.util.os") as mock_os_util, ): - mock_os_util.name = "nt" mock_os.getloadavg.return_value = (1, 2, 3) mock_os_util.path.isdir = isdir yield mock_os diff --git a/tests/components/systemmonitor/test_sensor.py b/tests/components/systemmonitor/test_sensor.py index a5f5e7623e9d..9b942257ec17 100644 --- a/tests/components/systemmonitor/test_sensor.py +++ b/tests/components/systemmonitor/test_sensor.py @@ -313,19 +313,6 @@ async def test_processor_temperature( assert await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - with patch("sys.platform", "nt"): - mock_psutil.sensors_temperatures.return_value = None - mock_psutil.sensors_temperatures.side_effect = AttributeError( - "sensors_temperatures not exist" - ) - mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - temp_entity = hass.states.get("sensor.system_monitor_processor_temperature") - assert temp_entity.state == STATE_UNAVAILABLE - assert await hass.config_entries.async_unload(mock_config_entry.entry_id) - await hass.async_block_till_done() - with patch("sys.platform", "darwin"): mock_psutil.sensors_temperatures.return_value = { "cpu0-thermal": [shwtemp("cpu0-thermal", 50.0, 60.0, 70.0)] diff --git a/tests/components/systemmonitor/test_util.py b/tests/components/systemmonitor/test_util.py index 582707f3574b..471f2f9e2cb4 100644 --- a/tests/components/systemmonitor/test_util.py +++ b/tests/components/systemmonitor/test_util.py @@ -52,7 +52,6 @@ async def test_disk_util( mock_psutil.psutil.disk_partitions.return_value = [ sdiskpart("test", "/", "ext4", ""), # Should be ok sdiskpart("test2", "/media/share", "ext4", ""), # Should be ok - sdiskpart("test3", "/incorrect", "", ""), # Should be skipped as no type sdiskpart( "proc", "/proc/run", "proc", "" ), # Should be skipped as in skipped disk types @@ -62,7 +61,6 @@ async def test_disk_util( "tmpfs", "", ), # Should be skipped as in skipped disk types - sdiskpart("test5", "E:", "cd", "cdrom"), # Should be skipped as cdrom ] mock_config_entry.add_to_hass(hass) @@ -71,13 +69,9 @@ async def test_disk_util( disk_sensor1 = hass.states.get("sensor.system_monitor_disk_free") disk_sensor2 = hass.states.get("sensor.system_monitor_disk_free_media_share") - disk_sensor3 = hass.states.get("sensor.system_monitor_disk_free_incorrect") - disk_sensor4 = hass.states.get("sensor.system_monitor_disk_free_proc_run") - disk_sensor5 = hass.states.get("sensor.system_monitor_disk_free_tmpfs") - disk_sensor6 = hass.states.get("sensor.system_monitor_disk_free_e") + disk_sensor3 = hass.states.get("sensor.system_monitor_disk_free_proc_run") + disk_sensor4 = hass.states.get("sensor.system_monitor_disk_free_tmpfs") assert disk_sensor1 is not None assert disk_sensor2 is not None assert disk_sensor3 is None assert disk_sensor4 is None - assert disk_sensor5 is None - assert disk_sensor6 is None From 485916265a5f95e57d3d10cfae2ba91c0f5c6107 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 22 Sep 2025 22:04:17 +0200 Subject: [PATCH 030/189] Fix manual updating of Nord Pool sensors (#152773) --- .../components/nordpool/coordinator.py | 25 +++++++-- homeassistant/components/nordpool/sensor.py | 7 ++- tests/components/nordpool/test_coordinator.py | 55 ++++++++++++++++++- 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/nordpool/coordinator.py b/homeassistant/components/nordpool/coordinator.py index 0cda1923125d..51bc0e638dd8 100644 --- a/homeassistant/components/nordpool/coordinator.py +++ b/homeassistant/components/nordpool/coordinator.py @@ -71,21 +71,34 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): self.unsub = async_track_point_in_utc_time( self.hass, self.fetch_data, self.get_next_interval(dt_util.utcnow()) ) + if self.config_entry.pref_disable_polling and not initial: + self.async_update_listeners() + return + try: + data = await self.handle_data(initial) + except UpdateFailed as err: + self.async_set_update_error(err) + return + self.async_set_updated_data(data) + + async def handle_data(self, initial: bool = False) -> DeliveryPeriodsData: + """Fetch data from Nord Pool.""" data = await self.api_call() if data and data.entries: current_day = dt_util.utcnow().strftime("%Y-%m-%d") for entry in data.entries: if entry.requested_date == current_day: LOGGER.debug("Data for current day found") - self.async_set_updated_data(data) - return + return data if data and not data.entries and not initial: # Empty response, use cache LOGGER.debug("No data entries received") - return - self.async_set_update_error( - UpdateFailed(translation_domain=DOMAIN, translation_key="no_day_data") - ) + return self.data + raise UpdateFailed(translation_domain=DOMAIN, translation_key="no_day_data") + + async def _async_update_data(self) -> DeliveryPeriodsData: + """Fetch the latest data from the source.""" + return await self.handle_data() async def api_call(self, retry: int = 3) -> DeliveryPeriodsData | None: """Make api call to retrieve data with retry if failure.""" diff --git a/homeassistant/components/nordpool/sensor.py b/homeassistant/components/nordpool/sensor.py index 4bde12afc3c5..90b0f44c2e57 100644 --- a/homeassistant/components/nordpool/sensor.py +++ b/homeassistant/components/nordpool/sensor.py @@ -34,8 +34,11 @@ def validate_prices( index: int, ) -> float | None: """Validate and return.""" - if (result := func(entity)[area][index]) is not None: - return result / 1000 + try: + if (result := func(entity)[area][index]) is not None: + return result / 1000 + except KeyError: + return None return None diff --git a/tests/components/nordpool/test_coordinator.py b/tests/components/nordpool/test_coordinator.py index 0f6b4341b938..e9af70d05bc8 100644 --- a/tests/components/nordpool/test_coordinator.py +++ b/tests/components/nordpool/test_coordinator.py @@ -16,10 +16,15 @@ from pynordpool import ( ) import pytest +from homeassistant.components.homeassistant import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, +) from homeassistant.components.nordpool.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from . import ENTRY_CONFIG @@ -34,12 +39,12 @@ async def test_coordinator( caplog: pytest.LogCaptureFixture, ) -> None: """Test the Nord Pool coordinator with errors.""" + await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) config_entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, data=ENTRY_CONFIG, ) - config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) @@ -88,7 +93,7 @@ async def test_coordinator( # Empty responses does not raise assert mock_data.call_count == 3 state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "0.94949" + assert state.state == "1.04203" assert "Empty response" in caplog.text with ( @@ -142,6 +147,50 @@ async def test_coordinator( state = hass.states.get("sensor.nord_pool_se3_current_price") assert state.state == "1.81983" + # Test manual polling + hass.config_entries.async_update_entry( + entry=config_entry, pref_disable_polling=True + ) + await hass.config_entries.async_reload(config_entry.entry_id) + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == "1.01177" + + # Prices should update without any polling made (read from cache) + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == "0.83553" + + # Test manually updating the data + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_periods", + wraps=get_client.async_get_delivery_periods, + ) as mock_data, + ): + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: "sensor.nord_pool_se3_current_price"}, + blocking=True, + ) + assert mock_data.call_count == 1 + + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == "0.79619" + + hass.config_entries.async_update_entry( + entry=config_entry, pref_disable_polling=False + ) + await hass.config_entries.async_reload(config_entry.entry_id) + with ( patch( "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", From 7bfdfb3fc79fb6e5a43d7c67493ee5e54784cbd7 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 22 Sep 2025 22:35:19 +0200 Subject: [PATCH 031/189] Bump pynecil to v4.2.0 (#152776) --- homeassistant/components/iron_os/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/iron_os/manifest.json b/homeassistant/components/iron_os/manifest.json index be2309ab3405..fb4d3fc15cda 100644 --- a/homeassistant/components/iron_os/manifest.json +++ b/homeassistant/components/iron_os/manifest.json @@ -14,5 +14,5 @@ "iot_class": "local_polling", "loggers": ["pynecil"], "quality_scale": "platinum", - "requirements": ["pynecil==4.1.1"] + "requirements": ["pynecil==4.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 980f42174d2b..17fb35213bdb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2183,7 +2183,7 @@ pymsteams==0.1.12 pymysensors==0.26.0 # homeassistant.components.iron_os -pynecil==4.1.1 +pynecil==4.2.0 # homeassistant.components.netgear pynetgear==0.10.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d4cab9136ef5..1ce833ab558e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1822,7 +1822,7 @@ pymonoprice==0.4 pymysensors==0.26.0 # homeassistant.components.iron_os -pynecil==4.1.1 +pynecil==4.2.0 # homeassistant.components.netgear pynetgear==0.10.10 From 2367df89d98340a616cd5698130192481b877722 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 22 Sep 2025 22:39:06 +0200 Subject: [PATCH 032/189] Bump reolink-aio to 0.15.2 (#152775) --- homeassistant/components/reolink/binary_sensor.py | 12 ++++++------ homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 5664bba25a3c..99039ab98220 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -74,21 +74,21 @@ BINARY_PUSH_SENSORS = ( ), ReolinkBinarySensorEntityDescription( key=PERSON_DETECTION_TYPE, - cmd_id=33, + cmd_id=[33, 600], translation_key="person", value=lambda api, ch: api.ai_detected(ch, PERSON_DETECTION_TYPE), supported=lambda api, ch: api.ai_supported(ch, PERSON_DETECTION_TYPE), ), ReolinkBinarySensorEntityDescription( key=VEHICLE_DETECTION_TYPE, - cmd_id=33, + cmd_id=[33, 600], translation_key="vehicle", value=lambda api, ch: api.ai_detected(ch, VEHICLE_DETECTION_TYPE), supported=lambda api, ch: api.ai_supported(ch, VEHICLE_DETECTION_TYPE), ), ReolinkBinarySensorEntityDescription( key=PET_DETECTION_TYPE, - cmd_id=33, + cmd_id=[33, 600], translation_key="pet", value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE), supported=lambda api, ch: ( @@ -98,14 +98,14 @@ BINARY_PUSH_SENSORS = ( ), ReolinkBinarySensorEntityDescription( key=PET_DETECTION_TYPE, - cmd_id=33, + cmd_id=[33, 600], translation_key="animal", value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE), supported=lambda api, ch: api.supported(ch, "ai_animal"), ), ReolinkBinarySensorEntityDescription( key=PACKAGE_DETECTION_TYPE, - cmd_id=33, + cmd_id=[33, 600], translation_key="package", value=lambda api, ch: api.ai_detected(ch, PACKAGE_DETECTION_TYPE), supported=lambda api, ch: api.ai_supported(ch, PACKAGE_DETECTION_TYPE), @@ -120,7 +120,7 @@ BINARY_PUSH_SENSORS = ( ), ReolinkBinarySensorEntityDescription( key="cry", - cmd_id=33, + cmd_id=[33, 600], translation_key="cry", value=lambda api, ch: api.ai_detected(ch, "cry"), supported=lambda api, ch: api.ai_supported(ch, "cry"), diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index a509a79eaa1b..634b8d909e65 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.15.1"] + "requirements": ["reolink-aio==0.15.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 17fb35213bdb..c667bf72bc35 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2688,7 +2688,7 @@ renault-api==0.4.0 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.15.1 +reolink-aio==0.15.2 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1ce833ab558e..9d5f97ea5bbe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2237,7 +2237,7 @@ renault-api==0.4.0 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.15.1 +reolink-aio==0.15.2 # homeassistant.components.rflink rflink==0.0.67 From 3c542b8d43b5cb331774c639711e8004b2727463 Mon Sep 17 00:00:00 2001 From: Rohan Kapoor Date: Mon, 22 Sep 2025 13:49:41 -0700 Subject: [PATCH 033/189] Only update Music Assistant URL on zeroconf discovery when current URL is unreachable (#152030) --- .../components/music_assistant/config_flow.py | 36 ++++- .../music_assistant/test_config_flow.py | 147 ++++++++++++++++++ 2 files changed, 176 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/music_assistant/config_flow.py b/homeassistant/components/music_assistant/config_flow.py index b00924c97a5d..09931040d6a9 100644 --- a/homeassistant/components/music_assistant/config_flow.py +++ b/homeassistant/components/music_assistant/config_flow.py @@ -68,7 +68,7 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN): self.server_info.server_id, raise_on_progress=False ) self._abort_if_unique_id_configured( - updates={CONF_URL: self.server_info.base_url}, + updates={CONF_URL: user_input[CONF_URL]}, reload_on_update=True, ) except CannotConnect: @@ -82,7 +82,7 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=DEFAULT_TITLE, data={ - CONF_URL: self.server_info.base_url, + CONF_URL: user_input[CONF_URL], }, ) @@ -103,14 +103,36 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN): # abort if discovery info is not what we expect if "server_id" not in discovery_info.properties: return self.async_abort(reason="missing_server_id") - # abort if we already have exactly this server_id - # reload the integration if the host got updated + self.server_info = ServerInfoMessage.from_dict(discovery_info.properties) await self.async_set_unique_id(self.server_info.server_id) - self._abort_if_unique_id_configured( - updates={CONF_URL: self.server_info.base_url}, - reload_on_update=True, + + # Check if we already have a config entry for this server_id + existing_entry = self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, self.server_info.server_id ) + + if existing_entry: + # Test connectivity to the current URL first + current_url = existing_entry.data[CONF_URL] + try: + await get_server_info(self.hass, current_url) + # Current URL is working, no need to update + return self.async_abort(reason="already_configured") + except CannotConnect: + # Current URL is not working, update to the discovered URL + # and continue to discovery confirm + self.hass.config_entries.async_update_entry( + existing_entry, + data={**existing_entry.data, CONF_URL: self.server_info.base_url}, + ) + # Schedule reload since URL changed + self.hass.config_entries.async_schedule_reload(existing_entry.entry_id) + else: + # No existing entry, proceed with normal flow + self._abort_if_unique_id_configured() + + # Test connectivity to the discovered URL try: await get_server_info(self.hass, self.server_info.base_url) except CannotConnect: diff --git a/tests/components/music_assistant/test_config_flow.py b/tests/components/music_assistant/test_config_flow.py index 2f623c1188d2..57eafd72ecf9 100644 --- a/tests/components/music_assistant/test_config_flow.py +++ b/tests/components/music_assistant/test_config_flow.py @@ -215,3 +215,150 @@ async def test_flow_zeroconf_connect_issue( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "cannot_connect" + + +async def test_user_url_different_from_server_base_url( + hass: HomeAssistant, + mock_get_server_info: AsyncMock, +) -> None: + """Test that user-provided URL is used even when different from server base_url.""" + # Mock server info with a different base_url than what user will provide + server_info = ServerInfoMessage.from_json( + await async_load_fixture(hass, "server_info_message.json", DOMAIN) + ) + server_info.base_url = "http://different-server:8095" + mock_get_server_info.return_value = server_info + + user_url = "http://user-provided-server:8095" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: user_url}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + # Verify that the user-provided URL is stored, not the server's base_url + assert result["data"] == { + CONF_URL: user_url, + } + assert result["result"].unique_id == "1234" + + +async def test_duplicate_user_with_different_urls( + hass: HomeAssistant, + mock_get_server_info: AsyncMock, +) -> None: + """Test duplicate detection works with different user URLs.""" + # Set up existing config entry with one URL + existing_url = "http://existing-server:8095" + existing_config_entry = MockConfigEntry( + domain=DOMAIN, + title="Music Assistant", + data={CONF_URL: existing_url}, + unique_id="1234", + ) + existing_config_entry.add_to_hass(hass) + + # Mock server info with different base_url + server_info = ServerInfoMessage.from_json( + await async_load_fixture(hass, "server_info_message.json", DOMAIN) + ) + server_info.base_url = "http://server-reported-url:8095" + mock_get_server_info.return_value = server_info + + # Try to configure with a different user URL but same server_id + new_user_url = "http://new-user-url:8095" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: new_user_url}, + ) + await hass.async_block_till_done() + + # Should detect as duplicate because server_id is the same + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_existing_entry_working_url( + hass: HomeAssistant, + mock_get_server_info: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test zeroconf flow when existing entry has working URL.""" + mock_config_entry.add_to_hass(hass) + + # Mock server info with different base_url + server_info = ServerInfoMessage.from_json( + await async_load_fixture(hass, "server_info_message.json", DOMAIN) + ) + server_info.base_url = "http://different-discovered-url:8095" + mock_get_server_info.return_value = server_info + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DATA, + ) + await hass.async_block_till_done() + + # Should abort because current URL is working + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + # Verify the URL was not changed + assert mock_config_entry.data[CONF_URL] == "http://localhost:8095" + + +async def test_zeroconf_existing_entry_broken_url( + hass: HomeAssistant, + mock_get_server_info: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test zeroconf flow when existing entry has broken URL.""" + mock_config_entry.add_to_hass(hass) + + # Create modified zeroconf data with different base_url + modified_zeroconf_data = deepcopy(ZEROCONF_DATA) + modified_zeroconf_data.properties["base_url"] = "http://discovered-working-url:8095" + + # Mock server info with the discovered URL + server_info = ServerInfoMessage.from_json( + await async_load_fixture(hass, "server_info_message.json", DOMAIN) + ) + server_info.base_url = "http://discovered-working-url:8095" + mock_get_server_info.return_value = server_info + + # First call (testing current URL) should fail, second call (testing discovered URL) should succeed + mock_get_server_info.side_effect = [ + CannotConnect("cannot_connect"), # Current URL fails + server_info, # Discovered URL works + ] + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=modified_zeroconf_data, + ) + await hass.async_block_till_done() + + # Should proceed to discovery confirm because current URL is broken + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + # Verify the URL was updated in the config entry + updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert updated_entry.data[CONF_URL] == "http://discovered-working-url:8095" From d389141aeedea18545e31df1a918e07ebe9ef239 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Sep 2025 21:43:20 -0500 Subject: [PATCH 034/189] Bump aioesphomeapi to 41.6.0 (#152787) --- 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 d9245dc4339f..269b3874237f 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==41.5.0", + "aioesphomeapi==41.6.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.3.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index c667bf72bc35..6977bd18c92b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.5.0 +aioesphomeapi==41.6.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9d5f97ea5bbe..f59df41a02f8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.5.0 +aioesphomeapi==41.6.0 # homeassistant.components.flo aioflo==2021.11.0 From 3dd941eff7cdbc7f36ca1bcefcc113e6d6118c32 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 23 Sep 2025 02:12:24 -0400 Subject: [PATCH 035/189] Fix section and entity variable resolution for template platforms (#149660) Co-authored-by: Erik Montnemery --- homeassistant/components/template/config.py | 45 +++-- .../components/template/trigger_entity.py | 22 ++- tests/components/template/test_config.py | 165 +++++++++++++++++- tests/components/template/test_sensor.py | 59 +++++++ .../template/test_trigger_entity.py | 39 ++++- 5 files changed, 307 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 51ed3bf01551..bcbc95845887 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -176,7 +176,15 @@ TEMPLATE_BLUEPRINT_SCHEMA = vol.All( ) -async def _async_resolve_blueprints( +def _merge_section_variables(config: ConfigType, section_variables: ConfigType) -> None: + """Merges a template entity configuration's variables with the section variables.""" + if (variables := config.pop(CONF_VARIABLES, None)) and isinstance(variables, dict): + config[CONF_VARIABLES] = {**section_variables, **variables} + else: + config[CONF_VARIABLES] = section_variables + + +async def _async_resolve_template_config( hass: HomeAssistant, config: ConfigType, ) -> TemplateConfig: @@ -187,12 +195,11 @@ async def _async_resolve_blueprints( with suppress(ValueError): # Invalid config raw_config = dict(config) + config = _backward_compat_schema(config) if is_blueprint_instance_config(config): blueprints = async_get_blueprints(hass) - blueprint_inputs = await blueprints.async_inputs_from_config( - _backward_compat_schema(config) - ) + blueprint_inputs = await blueprints.async_inputs_from_config(config) raw_blueprint_inputs = blueprint_inputs.config_with_inputs config = blueprint_inputs.async_substitute() @@ -205,14 +212,32 @@ async def _async_resolve_blueprints( for prop in (CONF_NAME, CONF_UNIQUE_ID): if prop in config: config[platform][prop] = config.pop(prop) - # For regular template entities, CONF_VARIABLES should be removed because they just - # house input results for template entities. For Trigger based template entities - # CONF_VARIABLES should not be removed because the variables are always - # executed between the trigger and action. + # State based template entities remove CONF_VARIABLES because they pass + # blueprint inputs to the template entities. Trigger based template entities + # retain CONF_VARIABLES because the variables are always executed between + # the trigger and action. if CONF_TRIGGERS not in config and CONF_VARIABLES in config: - config[platform][CONF_VARIABLES] = config.pop(CONF_VARIABLES) + _merge_section_variables(config[platform], config.pop(CONF_VARIABLES)) + raw_config = dict(config) + # Trigger based template entities retain CONF_VARIABLES because the variables are + # always executed between the trigger and action. + elif CONF_TRIGGERS not in config and CONF_VARIABLES in config: + # State based template entities have 2 layers of variables. Variables at the section level + # and variables at the entity level should be merged together at the entity level. + section_variables = config.pop(CONF_VARIABLES) + platform_config: list[ConfigType] | ConfigType + platforms = [platform for platform in PLATFORMS if platform in config] + for platform in platforms: + platform_config = config[platform] + if platform in PLATFORMS: + if isinstance(platform_config, dict): + platform_config = [platform_config] + + for entity_config in platform_config: + _merge_section_variables(entity_config, section_variables) + template_config = TemplateConfig(CONFIG_SECTION_SCHEMA(config)) template_config.raw_blueprint_inputs = raw_blueprint_inputs template_config.raw_config = raw_config @@ -225,7 +250,7 @@ async def async_validate_config_section( ) -> TemplateConfig: """Validate an entire config section for the template integration.""" - validated_config = await _async_resolve_blueprints(hass, config) + validated_config = await _async_resolve_template_config(hass, config) if CONF_TRIGGERS in validated_config: validated_config[CONF_TRIGGERS] = await async_validate_trigger_config( diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 66c57eb2aab4..e75d62352b50 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -4,8 +4,9 @@ from __future__ import annotations from typing import Any -from homeassistant.const import CONF_STATE +from homeassistant.const import CONF_STATE, CONF_VARIABLES from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.script_variables import ScriptVariables from homeassistant.helpers.template import _SENTINEL from homeassistant.helpers.trigger_template_entity import TriggerBaseEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -32,6 +33,8 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module TriggerBaseEntity.__init__(self, hass, config) AbstractTemplateEntity.__init__(self, hass, config) + self._entity_variables: ScriptVariables | None = config.get(CONF_VARIABLES) + self._rendered_entity_variables: dict | None = None self._state_render_error = False async def async_added_to_hass(self) -> None: @@ -63,9 +66,7 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module @callback def _render_script_variables(self) -> dict: """Render configured variables.""" - if self.coordinator.data is None: - return {} - return self.coordinator.data["run_variables"] or {} + return self._rendered_entity_variables or {} def _render_templates(self, variables: dict[str, Any]) -> None: """Render templates.""" @@ -92,7 +93,18 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module def _process_data(self) -> None: """Process new data.""" - variables = self._template_variables(self.coordinator.data["run_variables"]) + coordinator_variables = self.coordinator.data["run_variables"] + if self._entity_variables: + entity_variables = self._entity_variables.async_simple_render( + coordinator_variables + ) + self._rendered_entity_variables = { + **coordinator_variables, + **entity_variables, + } + else: + self._rendered_entity_variables = coordinator_variables + variables = self._template_variables(self._rendered_entity_variables) if self._render_availability_template(variables): self._render_templates(variables) diff --git a/tests/components/template/test_config.py b/tests/components/template/test_config.py index 77d4c4bc3c2b..88d6a2554f53 100644 --- a/tests/components/template/test_config.py +++ b/tests/components/template/test_config.py @@ -5,8 +5,12 @@ from __future__ import annotations import pytest import voluptuous as vol -from homeassistant.components.template.config import CONFIG_SECTION_SCHEMA +from homeassistant.components.template.config import ( + CONFIG_SECTION_SCHEMA, + async_validate_config_section, +) from homeassistant.core import HomeAssistant +from homeassistant.helpers.script_variables import ScriptVariables from homeassistant.helpers.template import Template @@ -93,3 +97,162 @@ async def test_invalid_default_entity_id( } with pytest.raises(vol.Invalid): CONFIG_SECTION_SCHEMA(config) + + +@pytest.mark.parametrize( + ("config", "expected"), + [ + ( + { + "variables": {"a": 1}, + "button": { + "press": { + "service": "test.automation", + "data_template": {"caller": "{{ this.entity_id }}"}, + }, + "variables": {"b": 2}, + "device_class": "restart", + "unique_id": "test", + "name": "test", + "icon": "mdi:test", + }, + }, + {"a": 1, "b": 2}, + ), + ( + { + "variables": {"a": 1}, + "button": [ + { + "press": { + "service": "test.automation", + "data_template": {"caller": "{{ this.entity_id }}"}, + }, + "variables": {"b": 2}, + "device_class": "restart", + "unique_id": "test", + "name": "test", + "icon": "mdi:test", + } + ], + }, + {"a": 1, "b": 2}, + ), + ( + { + "variables": {"a": 1}, + "button": [ + { + "press": { + "service": "test.automation", + "data_template": {"caller": "{{ this.entity_id }}"}, + }, + "variables": {"a": 2, "b": 2}, + "device_class": "restart", + "unique_id": "test", + "name": "test", + "icon": "mdi:test", + } + ], + }, + {"a": 2, "b": 2}, + ), + ( + { + "variables": {"a": 1}, + "button": { + "press": { + "service": "test.automation", + "data_template": {"caller": "{{ this.entity_id }}"}, + }, + "device_class": "restart", + "unique_id": "test", + "name": "test", + "icon": "mdi:test", + }, + }, + {"a": 1}, + ), + ( + { + "button": { + "press": { + "service": "test.automation", + "data_template": {"caller": "{{ this.entity_id }}"}, + }, + "variables": {"b": 2}, + "device_class": "restart", + "unique_id": "test", + "name": "test", + "icon": "mdi:test", + }, + }, + {"b": 2}, + ), + ], +) +async def test_combined_state_variables( + hass: HomeAssistant, config: dict, expected: dict +) -> None: + """Tests combining variables for state based template entities.""" + validated = await async_validate_config_section(hass, config) + assert "variables" not in validated + variables: ScriptVariables = validated["button"][0]["variables"] + assert variables.as_dict() == expected + + +@pytest.mark.parametrize( + ("config", "expected_root", "expected_entity"), + [ + ( + { + "trigger": {"trigger": "event", "event_type": "my_event"}, + "variables": {"a": 1}, + "binary_sensor": { + "name": "test", + "state": "{{ trigger.event.event_type }}", + "variables": {"b": 2}, + }, + }, + {"a": 1}, + {"b": 2}, + ), + ( + { + "triggers": {"trigger": "event", "event_type": "my_event"}, + "variables": {"a": 1}, + "binary_sensor": { + "name": "test", + "state": "{{ trigger.event.event_type }}", + }, + }, + {"a": 1}, + {}, + ), + ( + { + "trigger": {"trigger": "event", "event_type": "my_event"}, + "binary_sensor": { + "name": "test", + "state": "{{ trigger.event.event_type }}", + "variables": {"b": 2}, + }, + }, + {}, + {"b": 2}, + ), + ], +) +async def test_combined_trigger_variables( + hass: HomeAssistant, + config: dict, + expected_root: dict, + expected_entity: dict, +) -> None: + """Tests variable are not combined for trigger based template entities.""" + empty = ScriptVariables({}) + validated = await async_validate_config_section(hass, config) + root_variables: ScriptVariables = validated.get("variables", empty) + assert root_variables.as_dict() == expected_root + variables: ScriptVariables = validated["binary_sensor"][0].get("variables", empty) + assert variables.as_dict() == expected_entity diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 9aba85111929..0a940d111c5e 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -2298,6 +2298,65 @@ async def test_trigger_action(hass: HomeAssistant) -> None: assert events[0].context.parent_id == context.id +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": [ + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "variables": {"a": "{{ trigger.event.data.a }}"}, + "action": [ + { + "variables": {"b": "{{ a + 1 }}"}, + }, + {"event": "test_event2", "event_data": {"hello": "world"}}, + ], + "sensor": [ + { + "name": "Hello Name", + "state": "{{ a + b + c }}", + "variables": {"c": "{{ b + 1 }}"}, + "attributes": { + "a": "{{ a }}", + "b": "{{ b }}", + "c": "{{ c }}", + }, + } + ], + }, + ], + }, + ], +) +@pytest.mark.usefixtures("start_ha") +async def test_trigger_action_variables(hass: HomeAssistant) -> None: + """Test trigger entity with variables in an action works.""" + event = "test_event2" + context = Context() + events = async_capture_events(hass, event) + + state = hass.states.get("sensor.hello_name") + assert state is not None + assert state.state == STATE_UNKNOWN + + context = Context() + hass.bus.async_fire("test_event", {"a": 1}, context=context) + await hass.async_block_till_done() + + state = hass.states.get("sensor.hello_name") + assert state.state == str(1 + 2 + 3) + assert state.context is context + assert state.attributes["a"] == 1 + assert state.attributes["b"] == 2 + assert state.attributes["c"] == 3 + + assert len(events) == 1 + assert events[0].context.parent_id == context.id + + @pytest.mark.parametrize(("count", "domain"), [(1, template.DOMAIN)]) @pytest.mark.parametrize( "config", diff --git a/tests/components/template/test_trigger_entity.py b/tests/components/template/test_trigger_entity.py index 7077cbc6f29c..22201ab5ca91 100644 --- a/tests/components/template/test_trigger_entity.py +++ b/tests/components/template/test_trigger_entity.py @@ -7,6 +7,7 @@ from homeassistant.components.template.coordinator import TriggerUpdateCoordinat from homeassistant.const import CONF_ICON, CONF_NAME, CONF_STATE, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import template +from homeassistant.helpers.script_variables import ScriptVariables from homeassistant.helpers.trigger_template_entity import CONF_PICTURE _ICON_TEMPLATE = 'mdi:o{{ "n" if value=="on" else "ff" }}' @@ -123,18 +124,42 @@ async def test_template_state_syntax_error( async def test_script_variables_from_coordinator(hass: HomeAssistant) -> None: """Test script variables.""" - coordinator = TriggerUpdateCoordinator(hass, {}) - entity = TestEntity(hass, coordinator, {}) - assert entity._render_script_variables() == {} + hass.states.async_set("sensor.test", "1") - coordinator.data = {"run_variables": None} + coordinator = TriggerUpdateCoordinator( + hass, + { + "variables": ScriptVariables( + {"a": template.Template("{{ states('sensor.test') }}", hass), "c": 0} + ) + }, + ) + entity = TestEntity( + hass, + coordinator, + { + "state": template.Template("{{ 'on' }}", hass), + "variables": ScriptVariables( + {"b": template.Template("{{ a + 1 }}", hass), "c": 1} + ), + }, + ) + await coordinator._handle_triggered({}) + entity._process_data() + assert entity._render_script_variables() == {"a": 1, "b": 2, "c": 1} - assert entity._render_script_variables() == {} + hass.states.async_set("sensor.test", "2") - coordinator._execute_update({"value": STATE_ON}) + await coordinator._handle_triggered({"value": STATE_ON}) + entity._process_data() - assert entity._render_script_variables() == {"value": STATE_ON} + assert entity._render_script_variables() == { + "value": STATE_ON, + "a": 2, + "b": 3, + "c": 1, + } async def test_default_entity_id(hass: HomeAssistant) -> None: From a3cfd7f707d2632850d3bee8d4539cf4faa3387e Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 23 Sep 2025 09:03:01 +0200 Subject: [PATCH 036/189] Fix coordinator data handling in Bring integration (#152786) --- homeassistant/components/bring/coordinator.py | 1 + homeassistant/components/bring/event.py | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index 0a8d980a6aae..e03acca5bb55 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -205,6 +205,7 @@ class BringActivityCoordinator(BringBaseCoordinator[dict[str, BringActivityData] async def _async_update_data(self) -> dict[str, BringActivityData]: """Fetch activity data from bring.""" + self.lists = self.coordinator.lists list_dict: dict[str, BringActivityData] = {} for lst in self.lists: diff --git a/homeassistant/components/bring/event.py b/homeassistant/components/bring/event.py index e9e286dccf07..9cc41af10f77 100644 --- a/homeassistant/components/bring/event.py +++ b/homeassistant/components/bring/event.py @@ -43,7 +43,7 @@ async def async_setup_entry( ) lists_added |= new_lists - coordinator.activity.async_add_listener(add_entities) + coordinator.data.async_add_listener(add_entities) add_entities() @@ -67,7 +67,8 @@ class BringEventEntity(BringBaseEntity, EventEntity): def _async_handle_event(self) -> None: """Handle the activity event.""" - bring_list = self.coordinator.data[self._list_uuid] + if (bring_list := self.coordinator.data.get(self._list_uuid)) is None: + return last_event_triggered = self.state if bring_list.activity.timeline and ( last_event_triggered is None From 19fdea024caffe6ccf7e3c086acf151c2952c0bb Mon Sep 17 00:00:00 2001 From: Przemko92 <33545571+Przemko92@users.noreply.github.com> Date: Tue, 23 Sep 2025 09:48:53 +0200 Subject: [PATCH 037/189] Bump compit-inext-api to 0.3.1 (#152781) --- homeassistant/components/compit/climate.py | 7 ++++--- homeassistant/components/compit/coordinator.py | 2 +- homeassistant/components/compit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/compit/climate.py b/homeassistant/components/compit/climate.py index 40fae2b0de75..5647b3b58267 100644 --- a/homeassistant/components/compit/climate.py +++ b/homeassistant/components/compit/climate.py @@ -78,8 +78,8 @@ async def async_setup_entry( coordinator = entry.runtime_data climate_entities = [] - for device_id in coordinator.connector.devices: - device = coordinator.connector.devices[device_id] + for device_id in coordinator.connector.all_devices: + device = coordinator.connector.all_devices[device_id] if device.definition.device_class == CLIMATE_DEVICE_CLASS: climate_entities.append( @@ -140,7 +140,8 @@ class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntit def available(self) -> bool: """Return if entity is available.""" return ( - super().available and self.device_id in self.coordinator.connector.devices + super().available + and self.device_id in self.coordinator.connector.all_devices ) @property diff --git a/homeassistant/components/compit/coordinator.py b/homeassistant/components/compit/coordinator.py index 6eaf96184572..98668b260397 100644 --- a/homeassistant/components/compit/coordinator.py +++ b/homeassistant/components/compit/coordinator.py @@ -40,4 +40,4 @@ class CompitDataUpdateCoordinator(DataUpdateCoordinator[dict[int, DeviceInstance async def _async_update_data(self) -> dict[int, DeviceInstance]: """Update data via library.""" await self.connector.update_state(device_id=None) # Update all devices - return self.connector.devices + return self.connector.all_devices diff --git a/homeassistant/components/compit/manifest.json b/homeassistant/components/compit/manifest.json index 9a7aac816584..b686c406ad1f 100644 --- a/homeassistant/components/compit/manifest.json +++ b/homeassistant/components/compit/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["compit"], "quality_scale": "bronze", - "requirements": ["compit-inext-api==0.2.1"] + "requirements": ["compit-inext-api==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6977bd18c92b..1b7697142cc8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -736,7 +736,7 @@ colorlog==6.9.0 colorthief==0.2.1 # homeassistant.components.compit -compit-inext-api==0.2.1 +compit-inext-api==0.3.1 # homeassistant.components.concord232 concord232==0.15.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f59df41a02f8..bc858873cfbb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -645,7 +645,7 @@ colorlog==6.9.0 colorthief==0.2.1 # homeassistant.components.compit -compit-inext-api==0.2.1 +compit-inext-api==0.3.1 # homeassistant.components.xiaomi_miio construct==2.10.68 From d73309ba60290167294c19d8b33ee1de3b5f34f8 Mon Sep 17 00:00:00 2001 From: Karsten Bade Date: Tue, 23 Sep 2025 09:49:33 +0200 Subject: [PATCH 038/189] Bump SoCo to 0.30.12 (#152797) --- 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 fdb88e4b1361..bf1dea715441 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["soco", "sonos_websocket"], "quality_scale": "bronze", - "requirements": ["soco==0.30.11", "sonos-websocket==0.1.3"], + "requirements": ["soco==0.30.12", "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 1b7697142cc8..370631c95edd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2827,7 +2827,7 @@ smart-meter-texas==0.5.5 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.11 +soco==0.30.12 # homeassistant.components.solaredge_local solaredge-local==0.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc858873cfbb..6a5ffbb78627 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2343,7 +2343,7 @@ smart-meter-texas==0.5.5 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.11 +soco==0.30.12 # homeassistant.components.solarlog solarlog_cli==0.6.0 From e76bed4a837e0d44d01d78c3654d6a9cd3acda0f Mon Sep 17 00:00:00 2001 From: Matthias Lohr Date: Tue, 23 Sep 2025 10:21:06 +0200 Subject: [PATCH 039/189] Add reconfigure flow to tolo (#137609) Co-authored-by: Josef Zweck --- homeassistant/components/tolo/config_flow.py | 64 ++++++++--- homeassistant/components/tolo/strings.json | 3 +- tests/components/tolo/test_config_flow.py | 108 +++++++++++++++++-- 3 files changed, 149 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/tolo/config_flow.py b/homeassistant/components/tolo/config_flow.py index fed4ff332fc6..7b97fb20343b 100644 --- a/homeassistant/components/tolo/config_flow.py +++ b/homeassistant/components/tolo/config_flow.py @@ -1,14 +1,19 @@ -"""Config flow for tolo.""" +"""Config flow for TOLO integration.""" from __future__ import annotations import logging +from types import MappingProxyType from typing import Any from tololib import ToloClient, ToloCommunicationError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_HOST from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo @@ -17,13 +22,19 @@ from .const import DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) -class ToloSaunaConfigFlow(ConfigFlow, domain=DOMAIN): - """ConfigFlow for TOLO Sauna.""" + +class ToloConfigFlow(ConfigFlow, domain=DOMAIN): + """ConfigFlow for the TOLO Integration.""" VERSION = 1 - _discovered_host: str + _dhcp_discovery_info: DhcpServiceInfo | None = None @staticmethod def _check_device_availability(host: str) -> bool: @@ -37,7 +48,7 @@ class ToloSaunaConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle a flow initialized by the user.""" + """Handle a config flow initialized by the user.""" errors = {} if user_input is not None: @@ -47,19 +58,36 @@ class ToloSaunaConfigFlow(ConfigFlow, domain=DOMAIN): self._check_device_availability, user_input[CONF_HOST] ) - if not device_available: - errors["base"] = "cannot_connect" - else: - return self.async_create_entry( - title=DEFAULT_NAME, data={CONF_HOST: user_input[CONF_HOST]} - ) + if device_available: + if self.source == SOURCE_RECONFIGURE: + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), data_updates=user_input + ) + return self.async_create_entry(title=DEFAULT_NAME, data=user_input) + + errors["base"] = "cannot_connect" + + schema_values: dict[str, Any] | MappingProxyType[str, Any] = {} + if user_input is not None: + schema_values = user_input + elif self.source == SOURCE_RECONFIGURE: + schema_values = self._get_reconfigure_entry().data return self.async_show_form( step_id="user", - data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + data_schema=self.add_suggested_values_to_schema( + CONFIG_SCHEMA, + schema_values, + ), errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration config flow initialized by the user.""" + return await self.async_step_user(user_input) + async def async_step_dhcp( self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: @@ -73,7 +101,7 @@ class ToloSaunaConfigFlow(ConfigFlow, domain=DOMAIN): ) if device_available: - self._discovered_host = discovery_info.ip + self._dhcp_discovery_info = discovery_info return await self.async_step_confirm() return self.async_abort(reason="not_tolo_device") @@ -81,13 +109,15 @@ class ToloSaunaConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle user-confirmation of discovered node.""" + assert self._dhcp_discovery_info is not None + if user_input is not None: - self._async_abort_entries_match({CONF_HOST: self._discovered_host}) + self._async_abort_entries_match({CONF_HOST: self._dhcp_discovery_info.ip}) return self.async_create_entry( - title=DEFAULT_NAME, data={CONF_HOST: self._discovered_host} + title=DEFAULT_NAME, data={CONF_HOST: self._dhcp_discovery_info.ip} ) return self.async_show_form( step_id="confirm", - description_placeholders={CONF_HOST: self._discovered_host}, + description_placeholders={CONF_HOST: self._dhcp_discovery_info.ip}, ) diff --git a/homeassistant/components/tolo/strings.json b/homeassistant/components/tolo/strings.json index 82b6ecee9e7c..55c8274c19b1 100644 --- a/homeassistant/components/tolo/strings.json +++ b/homeassistant/components/tolo/strings.json @@ -16,7 +16,8 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "entity": { diff --git a/tests/components/tolo/test_config_flow.py b/tests/components/tolo/test_config_flow.py index e918edf70a47..b6cb8f91f825 100644 --- a/tests/components/tolo/test_config_flow.py +++ b/tests/components/tolo/test_config_flow.py @@ -12,6 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from tests.common import MockConfigEntry + MOCK_DHCP_DATA = DhcpServiceInfo( ip="127.0.0.2", macaddress="001122334455", hostname="mock_hostname" ) @@ -36,6 +38,22 @@ def coordinator_toloclient() -> Mock: yield toloclient +@pytest.fixture(name="config_entry") +async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a MockConfigEntry for testing.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="TOLO Steam Bath", + entry_id="1", + data={ + CONF_HOST: "127.0.0.1", + }, + ) + config_entry.add_to_hass(hass) + + return config_entry + + async def test_user_with_timed_out_host(hass: HomeAssistant, toloclient: Mock) -> None: """Test a user initiated config flow with provided host which times out.""" toloclient().get_status.side_effect = ToloCommunicationError @@ -64,25 +82,25 @@ async def test_user_walkthrough( toloclient().get_status.side_effect = lambda *args, **kwargs: None - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_HOST: "127.0.0.2"}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} toloclient().get_status.side_effect = lambda *args, **kwargs: object() - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_HOST: "127.0.0.1"}, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "TOLO Sauna" - assert result3["data"][CONF_HOST] == "127.0.0.1" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "TOLO Sauna" + assert result["data"][CONF_HOST] == "127.0.0.1" async def test_dhcp( @@ -116,3 +134,77 @@ async def test_dhcp_invalid_device(hass: HomeAssistant, toloclient: Mock) -> Non DOMAIN, context={"source": SOURCE_DHCP}, data=MOCK_DHCP_DATA ) assert result["type"] is FlowResultType.ABORT + + +async def test_reconfigure_walkthrough( + hass: HomeAssistant, + toloclient: Mock, + coordinator_toloclient: Mock, + config_entry: MockConfigEntry, +) -> None: + """Test a reconfigure flow without problems.""" + result = await config_entry.start_reconfigure_flow(hass) + + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.4"} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_HOST] == "127.0.0.4" + + +async def test_reconfigure_error_then_fix( + hass: HomeAssistant, + toloclient: Mock, + coordinator_toloclient: Mock, + config_entry: MockConfigEntry, +) -> None: + """Test a reconfigure flow which first fails and then recovers.""" + result = await config_entry.start_reconfigure_flow(hass) + assert result["step_id"] == "user" + + toloclient().get_status.side_effect = ToloCommunicationError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.5"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "cannot_connect" + + toloclient().get_status.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.4"} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_HOST] == "127.0.0.4" + + +async def test_reconfigure_duplicate_ip( + hass: HomeAssistant, + toloclient: Mock, + coordinator_toloclient: Mock, + config_entry: MockConfigEntry, +) -> None: + """Test a reconfigure flow where the user is trying to have to entries with the same IP.""" + config_entry2 = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.6"}, unique_id="second_entry" + ) + config_entry2.add_to_hass(hass) + + result = await config_entry.start_reconfigure_flow(hass) + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.6"} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert config_entry.data[CONF_HOST] == "127.0.0.1" From 38a5a3ed4b275376f919e7d12cbe8abd5897ba54 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 03:27:13 -0500 Subject: [PATCH 040/189] Handle wrong ESPHome device without encryption appearing at the configured IP (#152758) --- .../components/esphome/config_flow.py | 62 ++++++++++++------- tests/components/esphome/test_config_flow.py | 39 ++++++++++++ 2 files changed, 79 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 4efb0e494ef9..e1aedb90b3cb 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -138,6 +138,16 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return await self._async_authenticate_or_add() if error is None and entry_data.get(CONF_NOISE_PSK): + # Device was configured with encryption but now connects without it. + # Check if it's the same device before offering to remove encryption. + if self._reauth_entry.unique_id and self._device_mac: + expected_mac = format_mac(self._reauth_entry.unique_id) + actual_mac = format_mac(self._device_mac) + if expected_mac != actual_mac: + # Different device at the same IP - do not offer to remove encryption + return self._async_abort_wrong_device( + self._reauth_entry, expected_mac, actual_mac + ) return await self.async_step_reauth_encryption_removed_confirm() return await self.async_step_reauth_confirm() @@ -508,6 +518,28 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): CONF_DEVICE_NAME: self._device_name, } + @callback + def _async_abort_wrong_device( + self, entry: ConfigEntry, expected_mac: str, actual_mac: str + ) -> ConfigFlowResult: + """Abort flow because a different device was found at the IP address.""" + assert self._host is not None + assert self._device_name is not None + if self.source == SOURCE_RECONFIGURE: + reason = "reconfigure_unique_id_changed" + else: + reason = "reauth_unique_id_changed" + return self.async_abort( + reason=reason, + description_placeholders={ + "name": entry.data.get(CONF_DEVICE_NAME, entry.title), + "host": self._host, + "expected_mac": expected_mac, + "unexpected_mac": actual_mac, + "unexpected_device_name": self._device_name, + }, + ) + async def _async_validated_connection(self) -> ConfigFlowResult: """Handle validated connection.""" if self.source == SOURCE_RECONFIGURE: @@ -539,17 +571,10 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): # Reauth was triggered a while ago, and since than # a new device resides at the same IP address. assert self._device_name is not None - return self.async_abort( - reason="reauth_unique_id_changed", - description_placeholders={ - "name": self._reauth_entry.data.get( - CONF_DEVICE_NAME, self._reauth_entry.title - ), - "host": self._host, - "expected_mac": format_mac(self._reauth_entry.unique_id), - "unexpected_mac": format_mac(self.unique_id), - "unexpected_device_name": self._device_name, - }, + return self._async_abort_wrong_device( + self._reauth_entry, + format_mac(self._reauth_entry.unique_id), + format_mac(self.unique_id), ) async def _async_reconfig_validated_connection(self) -> ConfigFlowResult: @@ -589,17 +614,10 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): if self._reconfig_entry.data.get(CONF_DEVICE_NAME) == self._device_name: self._entry_with_name_conflict = self._reconfig_entry return await self.async_step_name_conflict() - return self.async_abort( - reason="reconfigure_unique_id_changed", - description_placeholders={ - "name": self._reconfig_entry.data.get( - CONF_DEVICE_NAME, self._reconfig_entry.title - ), - "host": self._host, - "expected_mac": format_mac(self._reconfig_entry.unique_id), - "unexpected_mac": format_mac(self.unique_id), - "unexpected_device_name": self._device_name, - }, + return self._async_abort_wrong_device( + self._reconfig_entry, + format_mac(self._reconfig_entry.unique_id), + format_mac(self.unique_id), ) async def async_step_encryption_key( diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index e0da680afe3f..f3bb1c77e408 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -1458,6 +1458,45 @@ async def test_reauth_encryption_key_removed(hass: HomeAssistant) -> None: assert entry.data[CONF_NOISE_PSK] == "" +async def test_reauth_different_device_at_same_address( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reauth aborts when a different device is found at the same IP address.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: VALID_NOISE_PSK, + CONF_DEVICE_NAME: "old_device", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + # Mock a different device at the same IP (different MAC address) + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, + name="new_device", + legacy_bluetooth_proxy_version=0, + # Different MAC address than the entry + mac_address="AA:BB:CC:DD:EE:FF", + esphome_version="1.0.0", + ) + + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_unique_id_changed" + assert result["description_placeholders"] == { + "name": "old_device", + "host": "127.0.0.1", + "expected_mac": "11:22:33:44:55:aa", + "unexpected_mac": "aa:bb:cc:dd:ee:ff", + "unexpected_device_name": "new_device", + } + + @pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_discovery_dhcp_updates_host( hass: HomeAssistant, mock_client: APIClient From a19e37844739b245d6bbc005e641defa90df6829 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 23 Sep 2025 10:44:36 +0200 Subject: [PATCH 041/189] Add Tuya test fixture files (#152795) --- tests/components/tuya/__init__.py | 15 + .../tuya/fixtures/cl_rD7uqAAgQOpSA2Rx.json | 37 + .../tuya/fixtures/clkg_xqvhthwkbmp3aghs.json | 114 ++ .../tuya/fixtures/cs_b9oyi2yofflroq1g.json | 134 +++ .../tuya/fixtures/cz_0fHWRe8ULjtmnBNd.json | 149 +++ .../tuya/fixtures/cz_IGzCi97RpN2Lf9cu.json | 149 +++ .../tuya/fixtures/cz_PGEkBctAbtzKOZng.json | 54 + .../tuya/fixtures/cz_mQUhiTg9kwydBFBd.json | 87 ++ .../components/tuya/fixtures/cz_piuensvr.json | 33 + .../tuya/fixtures/cz_qxJSyTLEtX5WrzA9.json | 87 ++ .../tuya/fixtures/mcs_oxslv1c9.json | 39 + .../tuya/fixtures/qt_TtXKwTMwiPpURWLJ.json | 37 + .../tuya/fixtures/wfcon_plp0gnfcacdeqk5o.json | 21 + .../tuya/fixtures/wkf_9xfjixap.json | 85 ++ .../tuya/fixtures/wkf_p3dbf6qs.json | 85 ++ .../tuya/fixtures/wsdcg_qrztc3ev.json | 210 ++++ .../tuya/snapshots/test_binary_sensor.ambr | 49 + .../tuya/snapshots/test_climate.ambr | 151 +++ .../components/tuya/snapshots/test_cover.ambr | 101 ++ tests/components/tuya/snapshots/test_fan.ambr | 57 + .../tuya/snapshots/test_humidifier.ambr | 57 + .../components/tuya/snapshots/test_init.ambr | 465 +++++++ .../components/tuya/snapshots/test_light.ambr | 57 + .../tuya/snapshots/test_select.ambr | 61 + .../tuya/snapshots/test_sensor.ambr | 1068 +++++++++++++++++ .../tuya/snapshots/test_switch.ambr | 488 ++++++++ 26 files changed, 3890 insertions(+) create mode 100644 tests/components/tuya/fixtures/cl_rD7uqAAgQOpSA2Rx.json create mode 100644 tests/components/tuya/fixtures/clkg_xqvhthwkbmp3aghs.json create mode 100644 tests/components/tuya/fixtures/cs_b9oyi2yofflroq1g.json create mode 100644 tests/components/tuya/fixtures/cz_0fHWRe8ULjtmnBNd.json create mode 100644 tests/components/tuya/fixtures/cz_IGzCi97RpN2Lf9cu.json create mode 100644 tests/components/tuya/fixtures/cz_PGEkBctAbtzKOZng.json create mode 100644 tests/components/tuya/fixtures/cz_mQUhiTg9kwydBFBd.json create mode 100644 tests/components/tuya/fixtures/cz_piuensvr.json create mode 100644 tests/components/tuya/fixtures/cz_qxJSyTLEtX5WrzA9.json create mode 100644 tests/components/tuya/fixtures/mcs_oxslv1c9.json create mode 100644 tests/components/tuya/fixtures/qt_TtXKwTMwiPpURWLJ.json create mode 100644 tests/components/tuya/fixtures/wfcon_plp0gnfcacdeqk5o.json create mode 100644 tests/components/tuya/fixtures/wkf_9xfjixap.json create mode 100644 tests/components/tuya/fixtures/wkf_p3dbf6qs.json create mode 100644 tests/components/tuya/fixtures/wsdcg_qrztc3ev.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 6aba86680cb9..1d12b972e7e9 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -22,12 +22,15 @@ DEVICE_MOCKS = [ "cl_ebt12ypvexnixvtf", # https://github.com/tuya/tuya-home-assistant/issues/754 "cl_g1cp07dsqnbdbbki", # https://github.com/home-assistant/core/issues/139966 "cl_qqdxfdht", # https://github.com/orgs/home-assistant/discussions/539 + "cl_rD7uqAAgQOpSA2Rx", # https://github.com/home-assistant/core/issues/139966 "cl_zah67ekd", # https://github.com/home-assistant/core/issues/71242 "clkg_nhyj64w2", # https://github.com/home-assistant/core/issues/136055 "clkg_wltqkykhni0papzj", # https://github.com/home-assistant/core/issues/151635 + "clkg_xqvhthwkbmp3aghs", # https://github.com/home-assistant/core/issues/139966 "co2bj_yakol79dibtswovc", # https://github.com/home-assistant/core/issues/151784 "co2bj_yrr3eiyiacm31ski", # https://github.com/orgs/home-assistant/discussions/842 "cobj_hcdy5zrq3ikzthws", # https://github.com/orgs/home-assistant/discussions/482 + "cs_b9oyi2yofflroq1g", # https://github.com/home-assistant/core/issues/139966 "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 @@ -38,6 +41,7 @@ DEVICE_MOCKS = [ "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_0fHWRe8ULjtmnBNd", # https://github.com/home-assistant/core/issues/139966 "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 @@ -49,6 +53,8 @@ DEVICE_MOCKS = [ "cz_AiHXxAyyn7eAkLQY", # https://github.com/home-assistant/core/issues/150662 "cz_CHLZe9HQ6QIXujVN", # https://github.com/home-assistant/core/issues/149233 "cz_HBRBzv1UVBVfF6SL", # https://github.com/tuya/tuya-home-assistant/issues/754 + "cz_IGzCi97RpN2Lf9cu", # https://github.com/home-assistant/core/issues/139966 + "cz_PGEkBctAbtzKOZng", # https://github.com/home-assistant/core/issues/139966 "cz_anwgf2xugjxpkfxb", # https://github.com/orgs/home-assistant/discussions/539 "cz_cuhokdii7ojyw8k2", # https://github.com/home-assistant/core/issues/149704 "cz_dhto3y4uachr1wll", # https://github.com/orgs/home-assistant/discussions/169 @@ -62,10 +68,13 @@ DEVICE_MOCKS = [ "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_mQUhiTg9kwydBFBd", # https://github.com/home-assistant/core/issues/139966 "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_piuensvr", # https://github.com/home-assistant/core/issues/139966 "cz_qm0iq4nqnrlzh4qc", # https://github.com/home-assistant/core/issues/141278 + "cz_qxJSyTLEtX5WrzA9", # https://github.com/home-assistant/core/issues/139966 "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 @@ -153,6 +162,7 @@ DEVICE_MOCKS = [ "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_oxslv1c9", # https://github.com/home-assistant/core/issues/139966 "mcs_qxu3flpqjsc1kqu3", # https://github.com/home-assistant/core/issues/141278 "msp_3ddulzljdjjwkhoy", # https://github.com/orgs/home-assistant/discussions/262 "mzj_jlapoy5liocmtdvd", # https://github.com/home-assistant/core/issues/150662 @@ -168,6 +178,7 @@ DEVICE_MOCKS = [ "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 + "qt_TtXKwTMwiPpURWLJ", # https://github.com/home-assistant/core/issues/139966 "qxj_fsea1lat3vuktbt6", # https://github.com/orgs/home-assistant/discussions/318 "qxj_is2indt9nlth6esa", # https://github.com/home-assistant/core/issues/136472 "qxj_xbwbniyt6bgws9ia", # https://github.com/orgs/home-assistant/discussions/823 @@ -205,6 +216,7 @@ DEVICE_MOCKS = [ "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 + "wfcon_plp0gnfcacdeqk5o", # https://github.com/home-assistant/core/issues/139966 "wg2_2gowdgni", # https://github.com/home-assistant/core/issues/150856 "wg2_haclbl0qkqlf2qds", # https://github.com/orgs/home-assistant/discussions/517 "wg2_nwxr8qcu4seltoro", # https://github.com/orgs/home-assistant/discussions/430 @@ -221,6 +233,8 @@ DEVICE_MOCKS = [ "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 + "wkf_9xfjixap", # https://github.com/home-assistant/core/issues/139966 + "wkf_p3dbf6qs", # https://github.com/home-assistant/core/issues/139966 "wnykq_kzwdw5bpxlbs9h9g", # https://github.com/orgs/home-assistant/discussions/842 "wnykq_npbbca46yiug8ysk", # https://github.com/orgs/home-assistant/discussions/539 "wnykq_om518smspsaltzdi", # https://github.com/home-assistant/core/issues/150662 @@ -230,6 +244,7 @@ DEVICE_MOCKS = [ "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_qrztc3ev", # https://github.com/home-assistant/core/issues/139966 "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 diff --git a/tests/components/tuya/fixtures/cl_rD7uqAAgQOpSA2Rx.json b/tests/components/tuya/fixtures/cl_rD7uqAAgQOpSA2Rx.json new file mode 100644 index 000000000000..d50a48766a5c --- /dev/null +++ b/tests/components/tuya/fixtures/cl_rD7uqAAgQOpSA2Rx.json @@ -0,0 +1,37 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Kit-Blinds", + "category": "cl", + "product_id": "rD7uqAAgQOpSA2Rx", + "product_name": "Wi-Fi Curtian Switch", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2020-04-04T08:17:44+00:00", + "create_time": "2020-04-04T08:17:44+00:00", + "update_time": "2020-04-04T08:17:44+00:00", + "function": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "close", "stop"] + } + } + }, + "status": { + "control": "open" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/clkg_xqvhthwkbmp3aghs.json b/tests/components/tuya/fixtures/clkg_xqvhthwkbmp3aghs.json new file mode 100644 index 000000000000..0f90f2af3c28 --- /dev/null +++ b/tests/components/tuya/fixtures/clkg_xqvhthwkbmp3aghs.json @@ -0,0 +1,114 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Pergola", + "category": "clkg", + "product_id": "xqvhthwkbmp3aghs", + "product_name": "Curtain switch", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2023-05-15T12:00:44+00:00", + "create_time": "2023-05-15T12:00:44+00:00", + "update_time": "2023-05-15T12:00:44+00:00", + "function": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 10 + } + }, + "cur_calibration": { + "type": "Enum", + "value": { + "range": ["start", "end"] + } + }, + "switch_backlight": { + "type": "Boolean", + "value": {} + }, + "control_back_mode": { + "type": "Enum", + "value": { + "range": ["forward", "back"] + } + }, + "tr_timecon": { + "type": "Integer", + "value": { + "unit": "s", + "min": 10, + "max": 240, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 10 + } + }, + "cur_calibration": { + "type": "Enum", + "value": { + "range": ["start", "end"] + } + }, + "switch_backlight": { + "type": "Boolean", + "value": {} + }, + "control_back_mode": { + "type": "Enum", + "value": { + "range": ["forward", "back"] + } + }, + "tr_timecon": { + "type": "Integer", + "value": { + "unit": "s", + "min": 10, + "max": 240, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "control": "stop", + "percent_control": 0, + "cur_calibration": "end", + "switch_backlight": false, + "control_back_mode": "forward", + "tr_timecon": 32 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cs_b9oyi2yofflroq1g.json b/tests/components/tuya/fixtures/cs_b9oyi2yofflroq1g.json new file mode 100644 index 000000000000..ad35e3c0e453 --- /dev/null +++ b/tests/components/tuya/fixtures/cs_b9oyi2yofflroq1g.json @@ -0,0 +1,134 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Living room dehumidifier", + "category": "cs", + "product_id": "b9oyi2yofflroq1g", + "product_name": "Dehumidifier ", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-02-25T10:34:41+00:00", + "create_time": "2025-02-25T10:34:41+00:00", + "update_time": "2025-02-25T10:34:41+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"] + } + }, + "swing": { + "type": "Boolean", + "value": {} + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "uv": { + "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"] + } + }, + "humidity_indoor": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "swing": { + "type": "Boolean", + "value": {} + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "uv": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h"] + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["E1", "E2"] + } + } + }, + "status": { + "switch": false, + "dehumidify_set_value": 47, + "fan_speed_enum": "high", + "humidity_indoor": 48, + "swing": true, + "anion": false, + "uv": false, + "child_lock": false, + "countdown_set": "cancel", + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_0fHWRe8ULjtmnBNd.json b/tests/components/tuya/fixtures/cz_0fHWRe8ULjtmnBNd.json new file mode 100644 index 000000000000..ea3e338ac1ba --- /dev/null +++ b/tests/components/tuya/fixtures/cz_0fHWRe8ULjtmnBNd.json @@ -0,0 +1,149 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Weihnachten3", + "category": "cz", + "product_id": "0fHWRe8ULjtmnBNd", + "product_name": "SP22-10A", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2018-12-07T12:58:37+00:00", + "create_time": "2018-12-07T12:58:37+00:00", + "update_time": "2018-12-07T12:58:37+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 + } + }, + "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 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["ov_cr", "ov_vol", "ov_pwr", "ls_cr", "ls_vol", "ls_pow"] + } + } + }, + "status": { + "switch_1": false, + "countdown_1": 0, + "add_ele": 1, + "cur_current": 18, + "cur_power": 21, + "cur_voltage": 2351, + "voltage_coe": 638, + "electric_coe": 31090, + "power_coe": 17883, + "electricity_coe": 1165, + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_IGzCi97RpN2Lf9cu.json b/tests/components/tuya/fixtures/cz_IGzCi97RpN2Lf9cu.json new file mode 100644 index 000000000000..4f2a7287a3bc --- /dev/null +++ b/tests/components/tuya/fixtures/cz_IGzCi97RpN2Lf9cu.json @@ -0,0 +1,149 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "N4-Auto", + "category": "cz", + "product_id": "IGzCi97RpN2Lf9cu", + "product_name": "Smart Socket", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2020-11-15T07:45:07+00:00", + "create_time": "2020-11-15T07:45:07+00:00", + "update_time": "2020-11-15T07:45:07+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 + } + }, + "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 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["ov_cr", "ov_vol", "ov_pwr", "ls_cr", "ls_vol", "ls_pow"] + } + } + }, + "status": { + "switch_1": false, + "countdown_1": 0, + "add_ele": 1, + "cur_current": 14, + "cur_power": 16, + "cur_voltage": 2287, + "voltage_coe": 757, + "electric_coe": 31906, + "power_coe": 21760, + "electricity_coe": 960, + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_PGEkBctAbtzKOZng.json b/tests/components/tuya/fixtures/cz_PGEkBctAbtzKOZng.json new file mode 100644 index 000000000000..16623e0dc285 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_PGEkBctAbtzKOZng.json @@ -0,0 +1,54 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Din", + "category": "cz", + "product_id": "PGEkBctAbtzKOZng", + "product_name": "Smart Plug", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2018-07-13T13:18:44+00:00", + "create_time": "2018-07-13T13:18:44+00:00", + "update_time": "2018-07-13T13:18:44+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "\u79d2", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "\u79d2", + "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_mQUhiTg9kwydBFBd.json b/tests/components/tuya/fixtures/cz_mQUhiTg9kwydBFBd.json new file mode 100644 index 000000000000..1dc272261042 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_mQUhiTg9kwydBFBd.json @@ -0,0 +1,87 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Waschmaschine", + "category": "cz", + "product_id": "mQUhiTg9kwydBFBd", + "product_name": "Smart Socket", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2018-08-13T17:59:14+00:00", + "create_time": "2018-08-13T17:59:14+00:00", + "update_time": "2018-08-13T17:59:14+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "\u79d2", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "\u79d2", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "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": 3000, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": false, + "countdown_1": 0, + "cur_current": 1, + "cur_power": 10455, + "cur_voltage": 2381 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_piuensvr.json b/tests/components/tuya/fixtures/cz_piuensvr.json new file mode 100644 index 000000000000..8489f44da8fc --- /dev/null +++ b/tests/components/tuya/fixtures/cz_piuensvr.json @@ -0,0 +1,33 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Signal repeater", + "category": "cz", + "product_id": "piuensvr", + "product_name": "Signal repeater", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-16T17:52:11+00:00", + "create_time": "2025-07-16T17:52:11+00:00", + "update_time": "2025-07-16T17:52:11+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/cz_qxJSyTLEtX5WrzA9.json b/tests/components/tuya/fixtures/cz_qxJSyTLEtX5WrzA9.json new file mode 100644 index 000000000000..7581500a3c96 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_qxJSyTLEtX5WrzA9.json @@ -0,0 +1,87 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "LivR", + "category": "cz", + "product_id": "qxJSyTLEtX5WrzA9", + "product_name": "Mini Smart Plug", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2018-02-21T13:32:25+00:00", + "create_time": "2018-02-21T13:32:25+00:00", + "update_time": "2018-02-21T13:32:25+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "\u79d2", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "\u79d2", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "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": 3000, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": false, + "countdown_1": 0, + "cur_current": 81, + "cur_power": 83, + "cur_voltage": 2352 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/mcs_oxslv1c9.json b/tests/components/tuya/fixtures/mcs_oxslv1c9.json new file mode 100644 index 000000000000..20a5060df69a --- /dev/null +++ b/tests/components/tuya/fixtures/mcs_oxslv1c9.json @@ -0,0 +1,39 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Window downstairs", + "category": "mcs", + "product_id": "oxslv1c9", + "product_name": "Contact Sensor", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-03-27T08:28:40+00:00", + "create_time": "2025-03-27T08:28:40+00:00", + "update_time": "2025-03-27T08:28:40+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": 100 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/qt_TtXKwTMwiPpURWLJ.json b/tests/components/tuya/fixtures/qt_TtXKwTMwiPpURWLJ.json new file mode 100644 index 000000000000..d66a997ee138 --- /dev/null +++ b/tests/components/tuya/fixtures/qt_TtXKwTMwiPpURWLJ.json @@ -0,0 +1,37 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Dining-Blinds", + "category": "qt", + "product_id": "TtXKwTMwiPpURWLJ", + "product_name": "Curtain switch", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2019-06-07T09:33:41+00:00", + "create_time": "2019-06-07T09:33:41+00:00", + "update_time": "2019-06-07T09:33:41+00:00", + "function": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + } + }, + "status": { + "control": "open" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wfcon_plp0gnfcacdeqk5o.json b/tests/components/tuya/fixtures/wfcon_plp0gnfcacdeqk5o.json new file mode 100644 index 000000000000..2aba962e5863 --- /dev/null +++ b/tests/components/tuya/fixtures/wfcon_plp0gnfcacdeqk5o.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": "plp0gnfcacdeqk5o", + "product_name": "Zigbee Gateway", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2023-10-14T06:02:39+00:00", + "create_time": "2023-10-14T06:02:39+00:00", + "update_time": "2023-10-14T06:02:39+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wkf_9xfjixap.json b/tests/components/tuya/fixtures/wkf_9xfjixap.json new file mode 100644 index 000000000000..88c6d6b3cc41 --- /dev/null +++ b/tests/components/tuya/fixtures/wkf_9xfjixap.json @@ -0,0 +1,85 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Empore", + "category": "wkf", + "product_id": "9xfjixap", + "product_name": "Smart Radiator Thermostat Controller", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-03-06T17:22:27+00:00", + "create_time": "2025-03-06T17:22:27+00:00", + "update_time": "2025-03-06T17:22:27+00:00", + "function": { + "mode": { + "type": "Enum", + "value": { + "range": ["auto", "manual", "off"] + } + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 50, + "max": 350, + "scale": 1, + "step": 10 + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "mode": { + "type": "Enum", + "value": { + "range": ["auto", "manual", "off"] + } + }, + "work_state": { + "type": "Enum", + "value": { + "range": ["opened", "closed"] + } + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 50, + "max": 350, + "scale": 1, + "step": 10 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 0, + "max": 500, + "scale": 1, + "step": 10 + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "mode": "manual", + "work_state": "opened", + "temp_set": 350, + "temp_current": 190, + "child_lock": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wkf_p3dbf6qs.json b/tests/components/tuya/fixtures/wkf_p3dbf6qs.json new file mode 100644 index 000000000000..0e083e877f4f --- /dev/null +++ b/tests/components/tuya/fixtures/wkf_p3dbf6qs.json @@ -0,0 +1,85 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Anbau", + "category": "wkf", + "product_id": "p3dbf6qs", + "product_name": "Smart Radiator Thermostat", + "online": false, + "sub": true, + "time_zone": "+02:00", + "active_time": "2023-10-14T06:23:27+00:00", + "create_time": "2023-10-14T06:23:27+00:00", + "update_time": "2023-10-14T06:23:27+00:00", + "function": { + "mode": { + "type": "Enum", + "value": { + "range": ["auto", "manual", "off"] + } + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 50, + "max": 350, + "scale": 1, + "step": 10 + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "mode": { + "type": "Enum", + "value": { + "range": ["auto", "manual", "off"] + } + }, + "work_state": { + "type": "Enum", + "value": { + "range": ["opened", "closed"] + } + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 50, + "max": 350, + "scale": 1, + "step": 10 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 0, + "max": 500, + "scale": 1, + "step": 10 + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "mode": "manual", + "work_state": "opened", + "temp_set": 250, + "temp_current": 220, + "child_lock": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wsdcg_qrztc3ev.json b/tests/components/tuya/fixtures/wsdcg_qrztc3ev.json new file mode 100644 index 000000000000..629e543706b1 --- /dev/null +++ b/tests/components/tuya/fixtures/wsdcg_qrztc3ev.json @@ -0,0 +1,210 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Temperature and humidity sensor", + "category": "wsdcg", + "product_id": "qrztc3ev", + "product_name": "Temperature and humidity sensor", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-03-29T14:26:44+00:00", + "create_time": "2025-03-29T14:26:44+00:00", + "update_time": "2025-03-29T14:26:44+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": 10 + } + }, + "minitemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 10 + } + }, + "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_sensitivity": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 3, + "max": 50, + "scale": 1, + "step": 1 + } + }, + "hum_sensitivity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 3, + "max": 10, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "va_temperature": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "va_humidity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "maxtemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 10 + } + }, + "minitemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 10 + } + }, + "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": ["cancel", "loweralarm", "upperalarm"] + } + }, + "hum_alarm": { + "type": "Enum", + "value": { + "range": ["cancel", "loweralarm", "upperalarm"] + } + }, + "temp_sensitivity": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 3, + "max": 50, + "scale": 1, + "step": 1 + } + }, + "hum_sensitivity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 3, + "max": 10, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "va_temperature": 200, + "va_humidity": 59, + "battery_percentage": 8, + "temp_unit_convert": "c", + "maxtemp_set": 600, + "minitemp_set": -100, + "maxhum_set": 70, + "minihum_set": 40, + "temp_alarm": "cancel", + "hum_alarm": "cancel", + "temp_sensitivity": 6, + "hum_sensitivity": 4 + }, + "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 6c2b5b3548a9..d0a1d5619ecb 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -1712,6 +1712,55 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[binary_sensor.window_downstairs_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.window_downstairs_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.9c1vlsxoscmdoorcontact_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.window_downstairs_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Window downstairs Door', + }), + 'context': , + 'entity_id': 'binary_sensor.window_downstairs_door', + '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_climate.ambr b/tests/components/tuya/snapshots/test_climate.ambr index 3ed6aa3bf585..344f638ddf2a 100644 --- a/tests/components/tuya/snapshots/test_climate.ambr +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -74,6 +74,80 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[climate.anbau-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_modes': list([ + '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.anbau', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + '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.sq6fbd3pfkw', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.anbau-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Anbau', + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_modes': list([ + 'off', + ]), + 'supported_features': , + 'target_temp_step': 1.0, + }), + 'context': , + 'entity_id': 'climate.anbau', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[climate.bathroom_radiator-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -364,6 +438,83 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[climate.empore-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_modes': list([ + '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.empore', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + '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.paxijfx9fkw', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.empore-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.0, + 'friendly_name': 'Empore', + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_mode': None, + 'preset_modes': list([ + 'off', + ]), + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': 35.0, + }), + 'context': , + 'entity_id': 'climate.empore', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- # name: test_platform_setup_and_discovery[climate.kabinet-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_cover.ambr b/tests/components/tuya/snapshots/test_cover.ambr index 42fecee7a932..e47af2155c44 100644 --- a/tests/components/tuya/snapshots/test_cover.ambr +++ b/tests/components/tuya/snapshots/test_cover.ambr @@ -151,6 +151,56 @@ 'state': 'open', }) # --- +# name: test_platform_setup_and_discovery[cover.kit_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.kit_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.xR2ASpOQgAAqu7Drlccontrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cover.kit_blinds_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'curtain', + 'friendly_name': 'Kit-Blinds Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.kit_blinds_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_platform_setup_and_discovery[cover.kitchen_blinds_blind-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -304,6 +354,57 @@ 'state': 'open', }) # --- +# name: test_platform_setup_and_discovery[cover.pergola_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.pergola_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.shga3pmbkwhthvqxgklccontrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cover.pergola_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'device_class': 'curtain', + 'friendly_name': 'Pergola Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.pergola_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- # name: test_platform_setup_and_discovery[cover.persiana_do_quarto_curtain-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index f2b615ec2697..88dfbf14ee6d 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -450,6 +450,63 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[fan.living_room_dehumidifier-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.living_room_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.g1qorlffoy2iyo9bsc', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fan.living_room_dehumidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living room dehumidifier', + 'percentage': 100, + 'percentage_step': 50.0, + 'preset_mode': None, + 'preset_modes': list([ + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.living_room_dehumidifier', + '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_humidifier.ambr b/tests/components/tuya/snapshots/test_humidifier.ambr index 46535810d7d9..5343b73e5e7e 100644 --- a/tests/components/tuya/snapshots/test_humidifier.ambr +++ b/tests/components/tuya/snapshots/test_humidifier.ambr @@ -111,3 +111,60 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[humidifier.living_room_dehumidifier-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.living_room_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.g1qorlffoy2iyo9bscswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[humidifier.living_room_dehumidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 48, + 'device_class': 'dehumidifier', + 'friendly_name': 'Living room dehumidifier', + 'humidity': 47, + 'max_humidity': 80, + 'min_humidity': 25, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.living_room_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 2a3f5687c525..399cc99e6b84 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -1146,6 +1146,68 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[9AzrW5XtELTySJxqzc] + 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', + '9AzrW5XtELTySJxqzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Mini Smart Plug', + 'model_id': 'qxJSyTLEtX5WrzA9', + 'name': 'LivR', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[9c1vlsxoscm] + 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', + '9c1vlsxoscm', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Contact Sensor', + 'model_id': 'oxslv1c9', + 'name': 'Window downstairs', + '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, @@ -1301,6 +1363,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[JLWRUpPiwMTwKXtTtq] + 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', + 'JLWRUpPiwMTwKXtTtq', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Curtain switch (unsupported)', + 'model_id': 'TtXKwTMwiPpURWLJ', + 'name': 'Dining-Blinds', + '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, @@ -2479,6 +2572,68 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[dBFBdywk9gTihUQmzc] + 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', + 'dBFBdywk9gTihUQmzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Socket', + 'model_id': 'mQUhiTg9kwydBFBd', + 'name': 'Waschmaschine', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[dNBnmtjLU8eRWHf0zc] + 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', + 'dNBnmtjLU8eRWHf0zc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'SP22-10A', + 'model_id': '0fHWRe8ULjtmnBNd', + 'name': 'Weihnachten3', + '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, @@ -3006,6 +3161,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[g1qorlffoy2iyo9bsc] + 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', + 'g1qorlffoy2iyo9bsc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Dehumidifier ', + 'model_id': 'b9oyi2yofflroq1g', + 'name': 'Living room dehumidifier', + '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, @@ -3316,6 +3502,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[gnZOKztbAtcBkEGPzc] + 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', + 'gnZOKztbAtcBkEGPzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Plug', + 'model_id': 'PGEkBctAbtzKOZng', + 'name': 'Din', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[gnqwzcph94wj2sl5nq] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -4897,6 +5114,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[o5kqedcacfng0plpnocfw] + 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', + 'o5kqedcacfng0plpnocfw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Zigbee Gateway (unsupported)', + 'model_id': 'plp0gnfcacdeqk5o', + 'name': 'Zigbee Gateway', + '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, @@ -5207,6 +5455,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[paxijfx9fkw] + 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', + 'paxijfx9fkw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Radiator Thermostat Controller', + 'model_id': '9xfjixap', + 'name': 'Empore', + '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, @@ -5858,6 +6137,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[rvsneuipzc] + 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', + 'rvsneuipzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Signal repeater', + 'model_id': 'piuensvr', + 'name': 'Signal repeater', + '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, @@ -5982,6 +6292,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[shga3pmbkwhthvqxgklc] + 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', + 'shga3pmbkwhthvqxgklc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Curtain switch', + 'model_id': 'xqvhthwkbmp3aghs', + 'name': 'Pergola', + '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, @@ -6075,6 +6416,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[sq6fbd3pfkw] + 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', + 'sq6fbd3pfkw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Radiator Thermostat', + 'model_id': 'p3dbf6qs', + 'name': 'Anbau', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[srp7cfjtn6sshwmt2gw] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -6509,6 +6881,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[uc9fL2NpR79iCzGIzc] + 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', + 'uc9fL2NpR79iCzGIzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Socket', + 'model_id': 'IGzCi97RpN2Lf9cu', + 'name': 'N4-Auto', + '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, @@ -6664,6 +7067,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[ve3ctzrqgcdsw] + 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', + 've3ctzrqgcdsw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Temperature and humidity sensor', + 'model_id': 'qrztc3ev', + 'name': 'Temperature and humidity sensor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[vnj3sa6mqahro6phjd] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -6974,6 +7408,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[xR2ASpOQgAAqu7Drlc] + 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', + 'xR2ASpOQgAAqu7Drlc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Wi-Fi Curtian Switch', + 'model_id': 'rD7uqAAgQOpSA2Rx', + 'name': 'Kit-Blinds', + '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, diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index c8d7556fa11c..b50bb1804be4 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -2408,6 +2408,63 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[light.pergola_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.pergola_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.shga3pmbkwhthvqxgklcswitch_backlight', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.pergola_backlight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'Pergola Backlight', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.pergola_backlight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[light.plafond_bureau-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index ce90522885d6..31862ae9d6cf 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -3136,6 +3136,67 @@ 'state': 'power_on', }) # --- +# name: test_platform_setup_and_discovery[select.living_room_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.living_room_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.g1qorlffoy2iyo9bsccountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.living_room_dehumidifier_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living room dehumidifier Countdown', + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'context': , + 'entity_id': 'select.living_room_dehumidifier_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cancel', + }) +# --- # name: test_platform_setup_and_discovery[select.mesa_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 0b428f8e30d7..f2769f832402 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -10241,6 +10241,233 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[sensor.living_room_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.living_room_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.g1qorlffoy2iyo9bschumidity_indoor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.living_room_dehumidifier_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Living room dehumidifier Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.living_room_dehumidifier_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '48.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.livr_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.livr_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.9AzrW5XtELTySJxqzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.livr_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'LivR Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.livr_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.081', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.livr_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.livr_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.9AzrW5XtELTySJxqzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.livr_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'LivR Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.livr_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '83.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.livr_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.livr_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.9AzrW5XtELTySJxqzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.livr_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'LivR Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.livr_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2352.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.lounge_dark_blind_last_operation_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -11957,6 +12184,232 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[sensor.n4_auto_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.n4_auto_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.uc9fL2NpR79iCzGIzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.n4_auto_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'N4-Auto Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.n4_auto_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.n4_auto_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.n4_auto_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.uc9fL2NpR79iCzGIzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.n4_auto_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'N4-Auto Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.n4_auto_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.n4_auto_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.n4_auto_total_energy', + 'has_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 energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.uc9fL2NpR79iCzGIzcadd_ele', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.n4_auto_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'N4-Auto Total energy', + 'state_class': , + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.n4_auto_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.n4_auto_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.n4_auto_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.uc9fL2NpR79iCzGIzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.n4_auto_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'N4-Auto Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.n4_auto_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[sensor.np_downstairs_north_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -17229,6 +17682,168 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.temperature_and_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.temperature_and_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.ve3ctzrqgcdswbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.temperature_and_humidity_sensor_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Temperature and humidity sensor Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.temperature_and_humidity_sensor_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.temperature_and_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.temperature_and_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.ve3ctzrqgcdswva_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.temperature_and_humidity_sensor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Temperature and humidity sensor Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.temperature_and_humidity_sensor_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '59.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.temperature_and_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.temperature_and_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.ve3ctzrqgcdswva_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.temperature_and_humidity_sensor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Temperature and humidity sensor Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.temperature_and_humidity_sensor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.tournesol_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -18186,6 +18801,180 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.waschmaschine_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.waschmaschine_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.dBFBdywk9gTihUQmzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.waschmaschine_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Waschmaschine Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.waschmaschine_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.001', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.waschmaschine_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.waschmaschine_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.dBFBdywk9gTihUQmzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.waschmaschine_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Waschmaschine Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.waschmaschine_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10455.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.waschmaschine_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.waschmaschine_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.dBFBdywk9gTihUQmzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.waschmaschine_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Waschmaschine Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.waschmaschine_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2381.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.water_fountain_filter_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -18290,6 +19079,232 @@ 'state': '7.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.weihnachten3_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.weihnachten3_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.dNBnmtjLU8eRWHf0zccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachten3_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Weihnachten3 Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.weihnachten3_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.018', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachten3_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.weihnachten3_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.dNBnmtjLU8eRWHf0zccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachten3_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Weihnachten3 Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.weihnachten3_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.1', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachten3_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.weihnachten3_total_energy', + 'has_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 energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.dNBnmtjLU8eRWHf0zcadd_ele', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachten3_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Weihnachten3 Total energy', + 'state_class': , + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.weihnachten3_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.001', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachten3_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.weihnachten3_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.dNBnmtjLU8eRWHf0zccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachten3_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Weihnachten3 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.weihnachten3_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '235.1', + }) +# --- # name: test_platform_setup_and_discovery[sensor.weihnachtsmann_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -19007,6 +20022,59 @@ 'state': '25.1', }) # --- +# name: test_platform_setup_and_discovery[sensor.window_downstairs_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.window_downstairs_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.9c1vlsxoscmbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.window_downstairs_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Window downstairs Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.window_downstairs_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- # 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_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 7df3249aa67d..eb12e64fe42c 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -486,6 +486,54 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.anbau_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.anbau_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.sq6fbd3pfkwchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.anbau_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Anbau Child lock', + }), + 'context': , + 'entity_id': 'switch.anbau_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[switch.apollo_light_socket_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2855,6 +2903,55 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.din_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.din_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.gnZOKztbAtcBkEGPzcswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.din_socket-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Din Socket', + }), + 'context': , + 'entity_id': 'switch.din_socket', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.droger_socket_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3386,6 +3483,54 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.empore_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.empore_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.paxijfx9fkwchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.empore_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Empore Child lock', + }), + 'context': , + 'entity_id': 'switch.empore_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.fakkel_veranda_socket_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5514,6 +5659,153 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[switch.living_room_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.living_room_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.g1qorlffoy2iyo9bscchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.living_room_dehumidifier_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living room dehumidifier Child lock', + 'icon': 'mdi:account-lock', + }), + 'context': , + 'entity_id': 'switch.living_room_dehumidifier_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.living_room_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.living_room_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.g1qorlffoy2iyo9bscanion', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.living_room_dehumidifier_ionizer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living room dehumidifier Ionizer', + 'icon': 'mdi:atom', + }), + 'context': , + 'entity_id': 'switch.living_room_dehumidifier_ionizer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.livr_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.livr_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.9AzrW5XtELTySJxqzcswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.livr_socket-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'LivR Socket', + }), + 'context': , + 'entity_id': 'switch.livr_socket', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.lounge_dark_blind_reverse-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5850,6 +6142,55 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.n4_auto_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.n4_auto_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.uc9fL2NpR79iCzGIzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.n4_auto_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'N4-Auto Socket 1', + }), + 'context': , + 'entity_id': 'switch.n4_auto_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[switch.office_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7260,6 +7601,55 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.signal_repeater_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.signal_repeater_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.rvsneuipzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.signal_repeater_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Signal repeater Socket 1', + }), + 'context': , + 'entity_id': 'switch.signal_repeater_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.smart_odor_eliminator_pro_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -8964,6 +9354,55 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[switch.waschmaschine_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.waschmaschine_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.dBFBdywk9gTihUQmzcswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.waschmaschine_socket-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Waschmaschine Socket', + }), + 'context': , + 'entity_id': 'switch.waschmaschine_socket', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.water_fountain_filter_reset-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -9108,6 +9547,55 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.weihnachten3_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.weihnachten3_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.dNBnmtjLU8eRWHf0zcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.weihnachten3_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Weihnachten3 Socket 1', + }), + 'context': , + 'entity_id': 'switch.weihnachten3_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.weihnachtsmann_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 58459cb80f05bc3d7e4e21446696813c753120ce Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 23 Sep 2025 10:47:37 +0200 Subject: [PATCH 042/189] Bump deebot-client to 14.0.0 (#152448) --- .../components/ecovacs/manifest.json | 2 +- homeassistant/components/ecovacs/select.py | 4 +- homeassistant/components/ecovacs/strings.json | 4 +- homeassistant/components/ecovacs/util.py | 5 -- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../fixtures/devices/n0vyif/device.json | 27 ++++++++ .../ecovacs/snapshots/test_select.ambr | 61 +++++++++++++++++++ .../ecovacs/snapshots/test_sensor.ambr | 4 ++ tests/components/ecovacs/test_select.py | 8 +++ tests/components/ecovacs/test_sensor.py | 2 +- 11 files changed, 110 insertions(+), 11 deletions(-) create mode 100644 tests/components/ecovacs/fixtures/devices/n0vyif/device.json diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index b45c06062eeb..3495126fd15f 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.7.0"] + "requirements": ["py-sucks==0.9.11", "deebot-client==14.0.0"] } diff --git a/homeassistant/components/ecovacs/select.py b/homeassistant/components/ecovacs/select.py index 84f86fdd2cd4..dc64f70da31e 100644 --- a/homeassistant/components/ecovacs/select.py +++ b/homeassistant/components/ecovacs/select.py @@ -33,7 +33,9 @@ class EcovacsSelectEntityDescription[EventT: Event]( ENTITY_DESCRIPTIONS: tuple[EcovacsSelectEntityDescription, ...] = ( EcovacsSelectEntityDescription[WaterAmountEvent]( - capability_fn=lambda caps: caps.water.amount if caps.water else None, + capability_fn=lambda caps: caps.water.amount + if caps.water and isinstance(caps.water.amount, CapabilitySetTypes) + else None, current_option_fn=lambda e: get_name_key(e.value), options_fn=lambda water: [get_name_key(amount) for amount in water.types], key="water_amount", diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 1be81ab12925..8d2d387f6e66 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -152,8 +152,10 @@ "station_state": { "name": "Station state", "state": { + "drying_mop": "Drying mop", "idle": "[%key:common::state::idle%]", - "emptying_dustbin": "Emptying dustbin" + "emptying_dustbin": "Emptying dustbin", + "washing_mop": "Washing mop" } }, "stats_area": { diff --git a/homeassistant/components/ecovacs/util.py b/homeassistant/components/ecovacs/util.py index 968ab92851b8..d26bd1981d7f 100644 --- a/homeassistant/components/ecovacs/util.py +++ b/homeassistant/components/ecovacs/util.py @@ -7,8 +7,6 @@ import random import string from typing import TYPE_CHECKING -from deebot_client.events.station import State - from homeassistant.core import HomeAssistant, callback from homeassistant.util import slugify @@ -49,9 +47,6 @@ def get_supported_entities( @callback def get_name_key(enum: Enum) -> str: """Return the lower case name of the enum.""" - if enum is State.EMPTYING: - # Will be fixed in the next major release of deebot-client - return "emptying_dustbin" return enum.name.lower() diff --git a/requirements_all.txt b/requirements_all.txt index 370631c95edd..1a6649fa5587 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -782,7 +782,7 @@ decora-wifi==1.4 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.7.0 +deebot-client==14.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6a5ffbb78627..4f28c7b5bcf5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -682,7 +682,7 @@ debugpy==1.8.16 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.7.0 +deebot-client==14.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/tests/components/ecovacs/fixtures/devices/n0vyif/device.json b/tests/components/ecovacs/fixtures/devices/n0vyif/device.json new file mode 100644 index 000000000000..71aec03a7869 --- /dev/null +++ b/tests/components/ecovacs/fixtures/devices/n0vyif/device.json @@ -0,0 +1,27 @@ +{ + "did": "E1234567890000000009", + "name": "E1234567890000000009", + "class": "n0vyif", + "resource": "eSQtNR9N", + "company": "eco-ng", + "service": { + "jmq": "jmq-ngiot-eu.dc.ww.ecouser.net", + "mqs": "api-ngiot.dc-eu.ww.ecouser.net" + }, + "deviceName": "DEEBOT X8 PRO OMNI", + "icon": "https://api-app.dc-eu.ww.ecouser.net/api/pim/file/get/66e3ac63a2928902a25d83a0", + "ota": true, + "UILogicId": "keplerh_ww_h_keplerh5", + "materialNo": "110-2417-0402", + "pid": "66daaa789dd37cf146cb1d2e", + "product_category": "DEEBOT", + "model": "KEPLER_BLACK_AI_INT", + "updateInfo": { + "needUpdate": false, + "changeLog": "" + }, + "nick": "X8 PRO OMNI", + "homeSort": 9999, + "status": 1, + "otaUpgrade": {} +} diff --git a/tests/components/ecovacs/snapshots/test_select.ambr b/tests/components/ecovacs/snapshots/test_select.ambr index 420a4a2d48e8..f8e269593d9b 100644 --- a/tests/components/ecovacs/snapshots/test_select.ambr +++ b/tests/components/ecovacs/snapshots/test_select.ambr @@ -1,4 +1,65 @@ # serializer version: 1 +# name: test_selects[n0vyif-entity_ids1][select.x8_pro_omni_work_mode:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'mop', + 'mop_after_vacuum', + 'vacuum', + 'vacuum_and_mop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.x8_pro_omni_work_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': 'Work mode', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'work_mode', + 'unique_id': 'E1234567890000000009_work_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[n0vyif-entity_ids1][select.x8_pro_omni_work_mode:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'X8 PRO OMNI Work mode', + 'options': list([ + 'mop', + 'mop_after_vacuum', + 'vacuum', + 'vacuum_and_mop', + ]), + }), + 'context': , + 'entity_id': 'select.x8_pro_omni_work_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'vacuum', + }) +# --- # name: test_selects[yna5x1-entity_ids0][select.ozmo_950_water_flow_level:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/ecovacs/snapshots/test_sensor.ambr b/tests/components/ecovacs/snapshots/test_sensor.ambr index c216c4c9e4a1..a3a891e6a87b 100644 --- a/tests/components/ecovacs/snapshots/test_sensor.ambr +++ b/tests/components/ecovacs/snapshots/test_sensor.ambr @@ -1288,6 +1288,8 @@ 'options': list([ 'idle', 'emptying_dustbin', + 'washing_mop', + 'drying_mop', ]), }), 'config_entry_id': , @@ -1327,6 +1329,8 @@ 'options': list([ 'idle', 'emptying_dustbin', + 'washing_mop', + 'drying_mop', ]), }), 'context': , diff --git a/tests/components/ecovacs/test_select.py b/tests/components/ecovacs/test_select.py index c3025d99cfab..538ab66bed0e 100644 --- a/tests/components/ecovacs/test_select.py +++ b/tests/components/ecovacs/test_select.py @@ -4,6 +4,7 @@ from deebot_client.command import Command from deebot_client.commands.json import SetWaterInfo from deebot_client.event_bus import EventBus from deebot_client.events.water_info import WaterAmount, WaterAmountEvent +from deebot_client.events.work_mode import WorkMode, WorkModeEvent import pytest from syrupy.assertion import SnapshotAssertion @@ -34,6 +35,7 @@ def platforms() -> Platform | list[Platform]: async def notify_events(hass: HomeAssistant, event_bus: EventBus): """Notify events.""" event_bus.notify(WaterAmountEvent(WaterAmount.ULTRAHIGH)) + event_bus.notify(WorkModeEvent(WorkMode.VACUUM)) await block_till_done(hass, event_bus) @@ -47,6 +49,12 @@ async def notify_events(hass: HomeAssistant, event_bus: EventBus): "select.ozmo_950_water_flow_level", ], ), + ( + "n0vyif", + [ + "select.x8_pro_omni_work_mode", + ], + ), ], ) async def test_selects( diff --git a/tests/components/ecovacs/test_sensor.py b/tests/components/ecovacs/test_sensor.py index 6c3900ccd197..5e7173912ba5 100644 --- a/tests/components/ecovacs/test_sensor.py +++ b/tests/components/ecovacs/test_sensor.py @@ -46,7 +46,7 @@ async def notify_events(hass: HomeAssistant, event_bus: EventBus): event_bus.notify(LifeSpanEvent(LifeSpan.FILTER, 56, 40 * 60)) event_bus.notify(LifeSpanEvent(LifeSpan.SIDE_BRUSH, 40, 20 * 60)) event_bus.notify(ErrorEvent(0, "NoError: Robot is operational")) - event_bus.notify(station.StationEvent(station.State.EMPTYING)) + event_bus.notify(station.StationEvent(station.State.EMPTYING_DUSTBIN)) await block_till_done(hass, event_bus) From f0c049237534be3d6bc68320436d8e9e8b063928 Mon Sep 17 00:00:00 2001 From: Lukas <12813107+lmaertin@users.noreply.github.com> Date: Tue, 23 Sep 2025 11:11:08 +0200 Subject: [PATCH 043/189] Add MAC address to Pooldose device (#152760) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/pooldose/config_flow.py | 28 ++++--- .../components/pooldose/coordinator.py | 1 + homeassistant/components/pooldose/entity.py | 14 +++- tests/components/pooldose/test_config_flow.py | 75 ++++++++++++++++++- 4 files changed, 104 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/pooldose/config_flow.py b/homeassistant/components/pooldose/config_flow.py index 36cd93b7515f..6deb4eafb13c 100644 --- a/homeassistant/components/pooldose/config_flow.py +++ b/homeassistant/components/pooldose/config_flow.py @@ -10,7 +10,7 @@ from pooldose.request_status import RequestStatus import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo @@ -31,9 +31,10 @@ class PooldoseConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 def __init__(self) -> None: - """Initialize the config flow and store the discovered IP address.""" + """Initialize the config flow and store the discovered IP address and MAC.""" super().__init__() self._discovered_ip: str | None = None + self._discovered_mac: str | None = None async def _validate_host( self, host: str @@ -71,13 +72,20 @@ class PooldoseConfigFlow(ConfigFlow, domain=DOMAIN): if not serial_number: return self.async_abort(reason="no_serial_number") - await self.async_set_unique_id(serial_number) + # If an existing entry is found + existing_entry = await self.async_set_unique_id(serial_number) + if existing_entry: + # Only update the MAC if it's not already set + if CONF_MAC not in existing_entry.data: + self.hass.config_entries.async_update_entry( + existing_entry, + data={**existing_entry.data, CONF_MAC: discovery_info.macaddress}, + ) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) - # Conditionally update IP and abort if entry exists - self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) - - # Continue with new device flow + # Else: Continue with new flow self._discovered_ip = discovery_info.ip + self._discovered_mac = discovery_info.macaddress return self.async_show_form( step_id="dhcp_confirm", description_placeholders={ @@ -91,10 +99,12 @@ class PooldoseConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Create the entry after the confirmation dialog.""" - discovered_ip = self._discovered_ip return self.async_create_entry( title=f"PoolDose {self.unique_id}", - data={CONF_HOST: discovered_ip}, + data={ + CONF_HOST: self._discovered_ip, + CONF_MAC: self._discovered_mac, + }, ) async def async_step_user( diff --git a/homeassistant/components/pooldose/coordinator.py b/homeassistant/components/pooldose/coordinator.py index 18261ff41561..cd2fa5d991d8 100644 --- a/homeassistant/components/pooldose/coordinator.py +++ b/homeassistant/components/pooldose/coordinator.py @@ -22,6 +22,7 @@ class PooldoseCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Coordinator for PoolDose integration.""" device_info: dict[str, Any] + config_entry: PooldoseConfigEntry def __init__( self, diff --git a/homeassistant/components/pooldose/entity.py b/homeassistant/components/pooldose/entity.py index 84ae216e8ba3..06c617ad524a 100644 --- a/homeassistant/components/pooldose/entity.py +++ b/homeassistant/components/pooldose/entity.py @@ -4,7 +4,8 @@ from __future__ import annotations from typing import Any -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.const import CONF_MAC +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -12,7 +13,9 @@ from .const import DOMAIN, MANUFACTURER from .coordinator import PooldoseCoordinator -def device_info(info: dict | None, unique_id: str) -> DeviceInfo: +def device_info( + info: dict | None, unique_id: str, mac: str | None = None +) -> DeviceInfo: """Create device info for PoolDose devices.""" if info is None: info = {} @@ -35,6 +38,7 @@ def device_info(info: dict | None, unique_id: str) -> DeviceInfo: configuration_url=( f"http://{info['IP']}/index.html" if info.get("IP") else None ), + connections={(CONNECTION_NETWORK_MAC, mac)} if mac else set(), ) @@ -56,7 +60,11 @@ class PooldoseEntity(CoordinatorEntity[PooldoseCoordinator]): self.entity_description = entity_description self.platform_name = platform_name self._attr_unique_id = f"{serial_number}_{entity_description.key}" - self._attr_device_info = device_info(device_properties, serial_number) + self._attr_device_info = device_info( + device_properties, + serial_number, + coordinator.config_entry.data.get(CONF_MAC), + ) @property def available(self) -> bool: diff --git a/tests/components/pooldose/test_config_flow.py b/tests/components/pooldose/test_config_flow.py index 777f2843bba2..354808c51d3c 100644 --- a/tests/components/pooldose/test_config_flow.py +++ b/tests/components/pooldose/test_config_flow.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components.pooldose.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo @@ -256,7 +256,8 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "PoolDose TEST123456789" - assert result["data"] == {CONF_HOST: "192.168.0.123"} + assert result["data"][CONF_HOST] == "192.168.0.123" + assert result["data"][CONF_MAC] == "a4e57caabbcc" assert result["result"].unique_id == "TEST123456789" @@ -355,3 +356,73 @@ async def test_dhcp_updates_host( assert result["reason"] == "already_configured" assert mock_config_entry.data[CONF_HOST] == "192.168.0.123" + + +async def test_dhcp_adds_mac_if_not_present( + hass: HomeAssistant, mock_pooldose_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test that DHCP flow adds MAC address if not already in config entry data.""" + # Create a config entry without MAC address + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="TEST123456789", + data={CONF_HOST: "192.168.1.100"}, + ) + entry.add_to_hass(hass) + + # Verify initial state has no MAC + assert CONF_MAC not in entry.data + + # Simulate DHCP discovery event + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.0.123", hostname="kommspot", macaddress="a4e57caabbcc" + ), + ) + + # Verify flow aborts as device is already configured + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + # Verify MAC was added to the config entry + assert entry.data[CONF_HOST] == "192.168.0.123" + assert entry.data[CONF_MAC] == "a4e57caabbcc" + + +async def test_dhcp_preserves_existing_mac( + hass: HomeAssistant, mock_pooldose_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test that DHCP flow preserves existing MAC in config entry data.""" + # Create a config entry with MAC address already set + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="TEST123456789", + data={ + CONF_HOST: "192.168.1.100", + CONF_MAC: "existing11aabb", # Existing MAC that should be preserved + }, + ) + entry.add_to_hass(hass) + + # Verify initial state has the expected MAC + assert entry.data[CONF_MAC] == "existing11aabb" + + # Simulate DHCP discovery event with different MAC + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.0.123", hostname="kommspot", macaddress="different22ccdd" + ), + ) + + # Verify flow aborts as device is already configured + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + # Verify MAC in config entry was NOT updated (original MAC preserved) + assert entry.data[CONF_HOST] == "192.168.0.123" # IP was updated + assert entry.data[CONF_MAC] == "existing11aabb" # MAC remains unchanged + assert entry.data[CONF_MAC] != "different22ccdd" # Not updated to new MAC From 22709506c6fec95172b5d867b0a80d0df6fcd3da Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 23 Sep 2025 11:21:11 +0200 Subject: [PATCH 044/189] Add Ecovacs custom water amount entity (#152782) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/ecovacs/number.py | 29 ++- homeassistant/components/ecovacs/select.py | 8 +- homeassistant/components/ecovacs/strings.json | 5 +- .../ecovacs/snapshots/test_number.ambr | 171 ++++++++++++++++++ tests/components/ecovacs/test_number.py | 38 +++- 5 files changed, 243 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/ecovacs/number.py b/homeassistant/components/ecovacs/number.py index 513a0d350f65..e8cefbd6d1f5 100644 --- a/homeassistant/components/ecovacs/number.py +++ b/homeassistant/components/ecovacs/number.py @@ -5,9 +5,11 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from deebot_client.capabilities import CapabilitySet +from deebot_client.capabilities import CapabilityNumber, CapabilitySet +from deebot_client.device import Device from deebot_client.events import CleanCountEvent, CutDirectionEvent, VolumeEvent from deebot_client.events.base import Event +from deebot_client.events.water_info import WaterCustomAmountEvent from homeassistant.components.number import ( NumberEntity, @@ -75,6 +77,19 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsNumberEntityDescription, ...] = ( native_step=1.0, mode=NumberMode.BOX, ), + EcovacsNumberEntityDescription[WaterCustomAmountEvent]( + capability_fn=lambda caps: ( + caps.water.amount + if caps.water and isinstance(caps.water.amount, CapabilityNumber) + else None + ), + value_fn=lambda e: e.value, + key="water_amount", + translation_key="water_amount", + entity_category=EntityCategory.CONFIG, + native_step=1.0, + mode=NumberMode.BOX, + ), ) @@ -100,6 +115,18 @@ class EcovacsNumberEntity[EventT: Event]( entity_description: EcovacsNumberEntityDescription + def __init__( + self, + device: Device, + capability: CapabilitySet[EventT, [int]], + entity_description: EcovacsNumberEntityDescription, + ) -> None: + """Initialize entity.""" + super().__init__(device, capability, entity_description) + if isinstance(capability, CapabilityNumber): + self._attr_native_min_value = capability.min + self._attr_native_max_value = capability.max + async def async_added_to_hass(self) -> None: """Set up the event listeners now that hass is ready.""" await super().async_added_to_hass() diff --git a/homeassistant/components/ecovacs/select.py b/homeassistant/components/ecovacs/select.py index dc64f70da31e..440141bbceed 100644 --- a/homeassistant/components/ecovacs/select.py +++ b/homeassistant/components/ecovacs/select.py @@ -33,9 +33,11 @@ class EcovacsSelectEntityDescription[EventT: Event]( ENTITY_DESCRIPTIONS: tuple[EcovacsSelectEntityDescription, ...] = ( EcovacsSelectEntityDescription[WaterAmountEvent]( - capability_fn=lambda caps: caps.water.amount - if caps.water and isinstance(caps.water.amount, CapabilitySetTypes) - else None, + capability_fn=lambda caps: ( + caps.water.amount + if caps.water and isinstance(caps.water.amount, CapabilitySetTypes) + else None + ), current_option_fn=lambda e: get_name_key(e.value), options_fn=lambda water: [get_name_key(amount) for amount in water.types], key="water_amount", diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 8d2d387f6e66..e69da61799ff 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -102,6 +102,9 @@ }, "volume": { "name": "Volume" + }, + "water_amount": { + "name": "Water flow level" } }, "sensor": { @@ -176,7 +179,7 @@ }, "select": { "water_amount": { - "name": "Water flow level", + "name": "[%key:component::ecovacs::entity::number::water_amount::name%]", "state": { "high": "[%key:common::state::high%]", "low": "[%key:common::state::low%]", diff --git a/tests/components/ecovacs/snapshots/test_number.ambr b/tests/components/ecovacs/snapshots/test_number.ambr index b89a490c7721..f35ee92ceb86 100644 --- a/tests/components/ecovacs/snapshots/test_number.ambr +++ b/tests/components/ecovacs/snapshots/test_number.ambr @@ -114,6 +114,177 @@ 'state': '3', }) # --- +# name: test_number_entities[n0vyif][number.x8_pro_omni_clean_count:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 4, + 'min': 1, + '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.x8_pro_omni_clean_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': 'Clean count', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'clean_count', + 'unique_id': 'E1234567890000000009_clean_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities[n0vyif][number.x8_pro_omni_clean_count:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'X8 PRO OMNI Clean count', + 'max': 4, + 'min': 1, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.x8_pro_omni_clean_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_number_entities[n0vyif][number.x8_pro_omni_volume:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 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.x8_pro_omni_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': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'E1234567890000000009_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities[n0vyif][number.x8_pro_omni_volume:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'X8 PRO OMNI Volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.x8_pro_omni_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_number_entities[n0vyif][number.x8_pro_omni_water_flow_level:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 50, + 'min': 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.x8_pro_omni_water_flow_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 flow level', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_amount', + 'unique_id': 'E1234567890000000009_water_amount', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities[n0vyif][number.x8_pro_omni_water_flow_level:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'X8 PRO OMNI Water flow level', + 'max': 50, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.x8_pro_omni_water_flow_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14', + }) +# --- # name: test_number_entities[yna5x1][number.ozmo_950_volume:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/ecovacs/test_number.py b/tests/components/ecovacs/test_number.py index dd7308e18fdf..02628554519e 100644 --- a/tests/components/ecovacs/test_number.py +++ b/tests/components/ecovacs/test_number.py @@ -3,8 +3,14 @@ from dataclasses import dataclass from deebot_client.command import Command -from deebot_client.commands.json import SetCutDirection, SetVolume -from deebot_client.events import CutDirectionEvent, Event, VolumeEvent +from deebot_client.commands.json import ( + SetCleanCount, + SetCutDirection, + SetVolume, + SetWaterInfo, +) +from deebot_client.events import CleanCountEvent, CutDirectionEvent, Event, VolumeEvent +from deebot_client.events.water_info import WaterCustomAmountEvent import pytest from syrupy.assertion import SnapshotAssertion @@ -68,8 +74,34 @@ class NumberTestCase: ), ], ), + ( + "n0vyif", + [ + NumberTestCase( + "number.x8_pro_omni_clean_count", + CleanCountEvent(1), + "1", + 4, + SetCleanCount(4), + ), + NumberTestCase( + "number.x8_pro_omni_volume", + VolumeEvent(5, 11), + "5", + 10, + SetVolume(10), + ), + NumberTestCase( + "number.x8_pro_omni_water_flow_level", + WaterCustomAmountEvent(14), + "14", + 7, + SetWaterInfo(custom_amount=7), + ), + ], + ), ], - ids=["yna5x1", "5xu9h3"], + ids=["yna5x1", "5xu9h3", "n0vyif"], ) async def test_number_entities( hass: HomeAssistant, From dd7f7be6adee76f2add98dcca8d3ff87bceabf70 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 23 Sep 2025 10:47:30 +0100 Subject: [PATCH 045/189] Move hardware thread add-on install after firmware install (#152800) --- .../homeassistant_connect_zbt2/config_flow.py | 2 +- .../firmware_config_flow.py | 56 ++-- .../homeassistant_sky_connect/config_flow.py | 2 +- .../homeassistant_yellow/config_flow.py | 2 +- .../test_config_flow.py | 56 ++-- .../test_config_flow.py | 288 ++++++++---------- .../test_config_flow_failures.py | 228 ++++++-------- .../test_config_flow.py | 56 ++-- .../homeassistant_yellow/test_config_flow.py | 40 +-- 9 files changed, 343 insertions(+), 387 deletions(-) diff --git a/homeassistant/components/homeassistant_connect_zbt2/config_flow.py b/homeassistant/components/homeassistant_connect_zbt2/config_flow.py index 19b7763cfd7d..49243e5a97df 100644 --- a/homeassistant/components/homeassistant_connect_zbt2/config_flow.py +++ b/homeassistant/components/homeassistant_connect_zbt2/config_flow.py @@ -90,7 +90,7 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol): firmware_name="OpenThread", expected_installed_firmware_type=ApplicationType.SPINEL, step_id="install_thread_firmware", - next_step_id="start_otbr_addon", + next_step_id="finish_thread_installation", ) diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 7f57350cc998..6df3e697fefe 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -415,11 +415,39 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): if self._picked_firmware_type == PickedFirmwareType.ZIGBEE: return await self.async_step_install_zigbee_firmware() - if result := await self._ensure_thread_addon_setup(): - return result + return await self.async_step_prepare_thread_installation() + + async def async_step_prepare_thread_installation( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Prepare for Thread installation by stopping the OTBR addon if needed.""" + if not is_hassio(self.hass): + return self.async_abort( + reason="not_hassio_thread", + description_placeholders=self._get_translation_placeholders(), + ) + + otbr_manager = get_otbr_addon_manager(self.hass) + addon_info = await self._async_get_addon_info(otbr_manager) + + if addon_info.state == AddonState.RUNNING: + # Stop the addon before continuing to flash firmware + await otbr_manager.async_stop_addon() return await self.async_step_install_thread_firmware() + async def async_step_finish_thread_installation( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Finish Thread installation by starting the OTBR addon.""" + otbr_manager = get_otbr_addon_manager(self.hass) + addon_info = await self._async_get_addon_info(otbr_manager) + + if addon_info.state == AddonState.NOT_INSTALLED: + return await self.async_step_install_otbr_addon() + + return await self.async_step_start_otbr_addon() + async def async_step_pick_firmware_zigbee( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -495,28 +523,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): """Continue the ZHA flow.""" raise NotImplementedError - async def _ensure_thread_addon_setup(self) -> ConfigFlowResult | None: - """Ensure the OTBR addon is set up and not running.""" - - # We install the OTBR addon no matter what, since it is required to use Thread - if not is_hassio(self.hass): - return self.async_abort( - reason="not_hassio_thread", - description_placeholders=self._get_translation_placeholders(), - ) - - otbr_manager = get_otbr_addon_manager(self.hass) - addon_info = await self._async_get_addon_info(otbr_manager) - - if addon_info.state == AddonState.NOT_INSTALLED: - return await self.async_step_install_otbr_addon() - - if addon_info.state == AddonState.RUNNING: - # Stop the addon before continuing to flash firmware - await otbr_manager.async_stop_addon() - - return None - async def async_step_pick_firmware_thread( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -572,7 +578,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): finally: self.addon_install_task = None - return self.async_show_progress_done(next_step_id="install_thread_firmware") + return self.async_show_progress_done(next_step_id="finish_thread_installation") async def async_step_start_otbr_addon( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index 197cb2ff2cee..7a9eff0b7413 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -106,7 +106,7 @@ class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol): firmware_name="OpenThread", expected_installed_firmware_type=ApplicationType.SPINEL, step_id="install_thread_firmware", - next_step_id="start_otbr_addon", + next_step_id="finish_thread_installation", ) diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index 7f84d0ddeb3b..efc218caeaa7 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -105,7 +105,7 @@ class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol): firmware_name="OpenThread", expected_installed_firmware_type=ApplicationType.SPINEL, step_id="install_thread_firmware", - next_step_id="start_otbr_addon", + next_step_id="finish_thread_installation", ) diff --git a/tests/components/homeassistant_connect_zbt2/test_config_flow.py b/tests/components/homeassistant_connect_zbt2/test_config_flow.py index 399361d453ff..e3b4f7a66f52 100644 --- a/tests/components/homeassistant_connect_zbt2/test_config_flow.py +++ b/tests/components/homeassistant_connect_zbt2/test_config_flow.py @@ -1,6 +1,7 @@ """Test the Home Assistant Connect ZBT-2 config flow.""" -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import AsyncMock, call, patch import pytest @@ -23,6 +24,16 @@ from .common import USB_DATA_ZBT2 from tests.common import MockConfigEntry +@pytest.fixture(name="supervisor") +def mock_supervisor_fixture() -> Generator[None]: + """Mock Supervisor.""" + with patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.is_hassio", + return_value=True, + ): + yield + + async def test_config_flow_zigbee( hass: HomeAssistant, ) -> None: @@ -51,16 +62,9 @@ async def test_config_flow_zigbee( step_id: str, next_step_id: str, ) -> ConfigFlowResult: - if next_step_id == "start_otbr_addon": - next_step_id = "pre_confirm_otbr" - - return await getattr(self, f"async_step_{next_step_id}")(user_input={}) + return await getattr(self, f"async_step_{next_step_id}")() with ( - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._ensure_thread_addon_setup", - return_value=None, - ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._install_firmware_step", autospec=True, @@ -113,8 +117,10 @@ async def test_config_flow_zigbee( assert zha_flow["step_id"] == "confirm" +@pytest.mark.usefixtures("addon_installed", "supervisor") async def test_config_flow_thread( hass: HomeAssistant, + start_addon: AsyncMock, ) -> None: """Test Thread config flow for Connect ZBT-2.""" fw_type = ApplicationType.SPINEL @@ -141,16 +147,9 @@ async def test_config_flow_thread( step_id: str, next_step_id: str, ) -> ConfigFlowResult: - if next_step_id == "start_otbr_addon": - next_step_id = "pre_confirm_otbr" - - return await getattr(self, f"async_step_{next_step_id}")(user_input={}) + return await getattr(self, f"async_step_{next_step_id}")() with ( - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._ensure_thread_addon_setup", - return_value=None, - ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._install_firmware_step", autospec=True, @@ -167,11 +166,23 @@ async def test_config_flow_thread( ), ), ): - confirm_result = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_otbr_addon" + + # Make sure the flow continues when the progress task is done. + await hass.async_block_till_done() + + confirm_result = await hass.config_entries.flow.async_configure( + result["flow_id"] + ) + + assert start_addon.call_count == 1 + assert start_addon.call_args == call("core_openthread_border_router") assert confirm_result["type"] is FlowResultType.FORM assert confirm_result["step_id"] == "confirm_otbr" @@ -244,20 +255,13 @@ async def test_options_flow( step_id: str, next_step_id: str, ) -> ConfigFlowResult: - if next_step_id == "start_otbr_addon": - next_step_id = "pre_confirm_otbr" - - return await getattr(self, f"async_step_{next_step_id}")(user_input={}) + return await getattr(self, f"async_step_{next_step_id}")() with ( patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.guess_hardware_owners", return_value=[], ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow._ensure_thread_addon_setup", - return_value=None, - ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow._install_firmware_step", autospec=True, diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 4040386562dd..c7c2535e3727 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -1,11 +1,12 @@ """Test the Home Assistant hardware firmware config flow.""" import asyncio -from collections.abc import Awaitable, Callable, Generator, Iterator +from collections.abc import AsyncGenerator, Awaitable, Callable, Iterator import contextlib from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, call, patch +from aiohasupervisor.models import AddonsOptions from aiohttp import ClientError from ha_silabs_firmware_client import ( FirmwareManifest, @@ -15,7 +16,6 @@ from ha_silabs_firmware_client import ( import pytest from yarl import URL -from homeassistant.components.hassio import AddonInfo, AddonState from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, @@ -25,7 +25,6 @@ from homeassistant.components.homeassistant_hardware.firmware_config_flow import from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, - get_otbr_addon_manager, ) from homeassistant.config_entries import ConfigEntry, ConfigFlowResult, OptionsFlow from homeassistant.core import HomeAssistant, callback @@ -77,7 +76,7 @@ class FakeFirmwareConfigFlow(BaseFirmwareConfigFlow, domain=TEST_DOMAIN): ) -> ConfigFlowResult: """Install Zigbee firmware.""" return await self._install_firmware_step( - fw_update_url=TEST_RELEASES_URL, + fw_update_url=str(TEST_RELEASES_URL), fw_type="fake_zigbee_ncp", firmware_name="Zigbee", expected_installed_firmware_type=ApplicationType.EZSP, @@ -90,12 +89,12 @@ class FakeFirmwareConfigFlow(BaseFirmwareConfigFlow, domain=TEST_DOMAIN): ) -> ConfigFlowResult: """Install Thread firmware.""" return await self._install_firmware_step( - fw_update_url=TEST_RELEASES_URL, + fw_update_url=str(TEST_RELEASES_URL), fw_type="fake_openthread_rcp", firmware_name="Thread", expected_installed_firmware_type=ApplicationType.SPINEL, step_id="install_thread_firmware", - next_step_id="start_otbr_addon", + next_step_id="finish_thread_installation", ) def _async_flow_finished(self) -> ConfigFlowResult: @@ -139,13 +138,27 @@ class FakeFirmwareOptionsFlowHandler(BaseFirmwareOptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Install Zigbee firmware.""" - return await self.async_step_pre_confirm_zigbee() + return await self._install_firmware_step( + fw_update_url=str(TEST_RELEASES_URL), + fw_type="fake_zigbee_ncp", + firmware_name="Zigbee", + expected_installed_firmware_type=ApplicationType.EZSP, + step_id="install_zigbee_firmware", + next_step_id="pre_confirm_zigbee", + ) async def async_step_install_thread_firmware( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Install Thread firmware.""" - return await self.async_step_start_otbr_addon() + return await self._install_firmware_step( + fw_update_url=str(TEST_RELEASES_URL), + fw_type="fake_openthread_rcp", + firmware_name="Thread", + expected_installed_firmware_type=ApplicationType.SPINEL, + step_id="install_thread_firmware", + next_step_id="finish_thread_installation", + ) def _async_flow_finished(self) -> ConfigFlowResult: """Create the config entry.""" @@ -166,7 +179,7 @@ class FakeFirmwareOptionsFlowHandler(BaseFirmwareOptionsFlow): @pytest.fixture(autouse=True) async def mock_test_firmware_platform( hass: HomeAssistant, -) -> Generator[None]: +) -> AsyncGenerator[None]: """Fixture for a test config flow.""" mock_module = MockModule( TEST_DOMAIN, async_setup_entry=AsyncMock(return_value=True) @@ -206,42 +219,20 @@ def create_mock_owner() -> Mock: @contextlib.contextmanager def mock_firmware_info( - hass: HomeAssistant, *, is_hassio: bool = True, probe_app_type: ApplicationType | None = ApplicationType.EZSP, probe_fw_version: str | None = "2.4.4.0", - otbr_addon_info: AddonInfo = AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.NOT_INSTALLED, - update_available=False, - version=None, - ), flash_app_type: ApplicationType = ApplicationType.EZSP, flash_fw_version: str | None = "7.4.4.0", -) -> Iterator[tuple[Mock, Mock]]: - """Mock the main addon states for the config flow.""" - mock_otbr_manager = Mock(spec_set=get_otbr_addon_manager(hass)) - mock_otbr_manager.addon_name = "OpenThread Border Router" - mock_otbr_manager.async_install_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_otbr_manager.async_uninstall_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_otbr_manager.async_start_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_otbr_manager.async_get_addon_info.return_value = otbr_addon_info - +) -> Iterator[Mock]: + """Mock the firmware info.""" mock_update_client = AsyncMock(spec_set=FirmwareUpdateClient) mock_update_client.async_update_data.return_value = FirmwareManifest( url=TEST_RELEASES_URL, html_url=TEST_RELEASES_URL / "html", created_at=utcnow(), - firmwares=[ + firmwares=( FirmwareMetadata( filename="fake_openthread_rcp_7.4.4.0_variant.gbl", checksum="sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", @@ -272,7 +263,7 @@ def mock_firmware_info( }, url=TEST_RELEASES_URL / "fake_zigbee_ncp_7.4.4.0_variant.gbl", ), - ], + ), ) if probe_app_type is None: @@ -318,14 +309,6 @@ def mock_firmware_info( return flashed_firmware_info with ( - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.get_otbr_addon_manager", - return_value=mock_otbr_manager, - ), - patch( - "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_manager", - return_value=mock_otbr_manager, - ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.is_hassio", return_value=is_hassio, @@ -351,7 +334,7 @@ def mock_firmware_info( side_effect=mock_flash_firmware, ), ): - yield mock_otbr_manager, mock_update_client + yield mock_update_client async def consume_progress_flow( @@ -385,7 +368,6 @@ async def test_config_flow_recommended(hass: HomeAssistant) -> None: assert init_result["step_id"] == "pick_firmware" with mock_firmware_info( - hass, probe_app_type=ApplicationType.SPINEL, flash_app_type=ApplicationType.EZSP, ): @@ -469,7 +451,6 @@ async def test_config_flow_zigbee_custom( assert init_result["step_id"] == "pick_firmware" with mock_firmware_info( - hass, probe_app_type=ApplicationType.SPINEL, flash_app_type=ApplicationType.EZSP, ): @@ -531,12 +512,11 @@ async def test_config_flow_firmware_index_download_fails_but_not_required( assert init_result["step_id"] == "pick_firmware" with mock_firmware_info( - hass, # The correct firmware is already installed probe_app_type=ApplicationType.EZSP, # An older version is probed, so an upgrade is attempted probe_fw_version="7.4.3.0", - ) as (_, mock_update_client): + ) as mock_update_client: # Mock the firmware download to fail mock_update_client.async_update_data.side_effect = ClientError() @@ -567,15 +547,12 @@ async def test_config_flow_firmware_download_fails_but_not_required( assert init_result["type"] is FlowResultType.MENU assert init_result["step_id"] == "pick_firmware" - with ( - mock_firmware_info( - hass, - # The correct firmware is already installed so installation isn't required - probe_app_type=ApplicationType.EZSP, - # An older version is probed, so an upgrade is attempted - probe_fw_version="7.4.3.0", - ) as (_, mock_update_client), - ): + with mock_firmware_info( + # The correct firmware is already installed so installation isn't required + probe_app_type=ApplicationType.EZSP, + # An older version is probed, so an upgrade is attempted + probe_fw_version="7.4.3.0", + ) as mock_update_client: mock_update_client.async_fetch_firmware.side_effect = ClientError() pick_result = await hass.config_entries.flow.async_configure( @@ -607,7 +584,6 @@ async def test_config_flow_doesnt_downgrade( with ( mock_firmware_info( - hass, probe_app_type=ApplicationType.EZSP, # An newer version is probed than what we offer probe_fw_version="7.5.0.0", @@ -642,7 +618,9 @@ async def test_config_flow_zigbee_skip_step_if_installed(hass: HomeAssistant) -> assert result["type"] is FlowResultType.MENU assert result["step_id"] == "pick_firmware" - with mock_firmware_info(hass, probe_app_type=ApplicationType.SPINEL): + with mock_firmware_info( + probe_app_type=ApplicationType.SPINEL, + ): # Pick the menu option: we skip installation, instead we directly run it result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -659,7 +637,6 @@ async def test_config_flow_zigbee_skip_step_if_installed(hass: HomeAssistant) -> # Done with mock_firmware_info( - hass, probe_app_type=ApplicationType.EZSP, ): await hass.async_block_till_done(wait_background_tasks=True) @@ -693,7 +670,12 @@ async def test_config_flow_auto_confirm_if_running(hass: HomeAssistant) -> None: } -async def test_config_flow_thread(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("addon_installed") +async def test_config_flow_thread( + hass: HomeAssistant, + set_addon_options: AsyncMock, + start_addon: AsyncMock, +) -> None: """Test the config flow.""" init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} @@ -703,10 +685,9 @@ async def test_config_flow_thread(hass: HomeAssistant) -> None: assert init_result["step_id"] == "pick_firmware" with mock_firmware_info( - hass, probe_app_type=ApplicationType.EZSP, flash_app_type=ApplicationType.SPINEL, - ) as (mock_otbr_manager, _): + ): # Pick the menu option pick_result = await hass.config_entries.flow.async_configure( init_result["flow_id"], @@ -714,27 +695,15 @@ async def test_config_flow_thread(hass: HomeAssistant) -> None: ) assert pick_result["type"] is FlowResultType.SHOW_PROGRESS - assert pick_result["progress_action"] == "install_addon" - assert pick_result["step_id"] == "install_otbr_addon" - assert pick_result["description_placeholders"]["firmware_type"] == "ezsp" - assert pick_result["description_placeholders"]["model"] == TEST_HARDWARE_NAME + assert pick_result["progress_action"] == "install_firmware" + assert pick_result["step_id"] == "install_thread_firmware" + description_placeholders = pick_result["description_placeholders"] + assert description_placeholders is not None + assert description_placeholders["firmware_type"] == "ezsp" + assert description_placeholders["model"] == TEST_HARDWARE_NAME await hass.async_block_till_done(wait_background_tasks=True) - mock_otbr_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={ - "device": "", - "baudrate": 460800, - "flow_control": True, - "autoflash_firmware": False, - }, - state=AddonState.NOT_RUNNING, - update_available=False, - version="1.2.3", - ) - # Progress the flow, it is now installing firmware confirm_otbr_result = await consume_progress_flow( hass, @@ -760,37 +729,36 @@ async def test_config_flow_thread(hass: HomeAssistant) -> None: "hardware": TEST_HARDWARE_NAME, } - assert mock_otbr_manager.async_set_addon_options.mock_calls == [ - call( - { - "device": TEST_DEVICE, + assert set_addon_options.call_args == call( + "core_openthread_border_router", + AddonsOptions( + config={ + "device": "/dev/SomeDevice123", "baudrate": 460800, "flow_control": True, "autoflash_firmware": False, - } - ) - ] + }, + ), + ) + assert start_addon.call_count == 1 + assert start_addon.call_args == call("core_openthread_border_router") -async def test_config_flow_thread_addon_already_installed(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("addon_installed") +async def test_config_flow_thread_addon_already_installed( + hass: HomeAssistant, + set_addon_options: AsyncMock, + start_addon: AsyncMock, +) -> None: """Test the Thread config flow, addon is already installed.""" init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) with mock_firmware_info( - hass, probe_app_type=ApplicationType.EZSP, flash_app_type=ApplicationType.SPINEL, - otbr_addon_info=AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.NOT_RUNNING, - update_available=False, - version=None, - ), - ) as (mock_otbr_manager, _): + ): # Pick the menu option pick_result = await hass.config_entries.flow.async_configure( init_result["flow_id"], @@ -813,16 +781,19 @@ async def test_config_flow_thread_addon_already_installed(hass: HomeAssistant) - assert confirm_otbr_result["step_id"] == "confirm_otbr" # The addon has been installed - assert mock_otbr_manager.async_set_addon_options.mock_calls == [ - call( - { - "device": TEST_DEVICE, + assert set_addon_options.call_args == call( + "core_openthread_border_router", + AddonsOptions( + config={ + "device": "/dev/SomeDevice123", "baudrate": 460800, "flow_control": True, - "autoflash_firmware": False, # And firmware flashing is disabled - } - ) - ] + "autoflash_firmware": False, + }, + ), + ) + assert start_addon.call_count == 1 + assert start_addon.call_args == call("core_openthread_border_router") # Finally, create the config entry create_result = await hass.config_entries.flow.async_configure( @@ -836,8 +807,13 @@ async def test_config_flow_thread_addon_already_installed(hass: HomeAssistant) - } -@pytest.mark.usefixtures("addon_store_info") -async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("addon_not_installed") +async def test_options_flow_zigbee_to_thread( + hass: HomeAssistant, + install_addon: AsyncMock, + set_addon_options: AsyncMock, + start_addon: AsyncMock, +) -> None: """Test the options flow, migrating Zigbee to Thread.""" config_entry = MockConfigEntry( domain=TEST_DOMAIN, @@ -854,16 +830,16 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) with mock_firmware_info( - hass, probe_app_type=ApplicationType.EZSP, flash_app_type=ApplicationType.SPINEL, - ) as (mock_otbr_manager, _): - # First step is confirmation + ): result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "pick_firmware" - assert result["description_placeholders"]["firmware_type"] == "ezsp" - assert result["description_placeholders"]["model"] == TEST_HARDWARE_NAME + description_placeholders = result["description_placeholders"] + assert description_placeholders is not None + assert description_placeholders["firmware_type"] == "ezsp" + assert description_placeholders["model"] == TEST_HARDWARE_NAME result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -871,49 +847,47 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["progress_action"] == "install_addon" - assert result["step_id"] == "install_otbr_addon" + assert result["step_id"] == "install_thread_firmware" + assert result["progress_action"] == "install_firmware" await hass.async_block_till_done(wait_background_tasks=True) - mock_otbr_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={ - "device": "", - "baudrate": 460800, - "flow_control": True, - "autoflash_firmware": False, - }, - state=AddonState.NOT_RUNNING, - update_available=False, - version="1.2.3", - ) + result = await hass.config_entries.options.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "install_otbr_addon" + assert result["progress_action"] == "install_addon" + + await hass.async_block_till_done(wait_background_tasks=True) - # Progress the flow, it is now configuring the addon and running it result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "start_otbr_addon" assert result["progress_action"] == "start_otbr_addon" - assert mock_otbr_manager.async_set_addon_options.mock_calls == [ - call( - { - "device": TEST_DEVICE, + await hass.async_block_till_done(wait_background_tasks=True) + + result = await hass.config_entries.options.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_otbr" + assert install_addon.call_count == 1 + assert install_addon.call_args == call("core_openthread_border_router") + assert set_addon_options.call_count == 1 + assert set_addon_options.call_args == call( + "core_openthread_border_router", + AddonsOptions( + config={ + "device": "/dev/SomeDevice123", "baudrate": 460800, "flow_control": True, "autoflash_firmware": False, - } - ) - ] - - await hass.async_block_till_done(wait_background_tasks=True) - - # The addon is now running - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_otbr" + }, + ), + ) + assert start_addon.call_count == 1 + assert start_addon.call_args == call("core_openthread_border_router") # We are now done result = await hass.config_entries.options.async_configure( @@ -951,7 +925,6 @@ async def test_options_flow_thread_to_zigbee(hass: HomeAssistant) -> None: assert description_placeholders["model"] == TEST_HARDWARE_NAME with mock_firmware_info( - hass, probe_app_type=ApplicationType.SPINEL, ): pick_result = await hass.config_entries.options.async_configure( @@ -963,15 +936,24 @@ async def test_options_flow_thread_to_zigbee(hass: HomeAssistant) -> None: assert pick_result["step_id"] == "zigbee_installation_type" with mock_firmware_info( - hass, probe_app_type=ApplicationType.EZSP, ): # We are now done - create_result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( pick_result["flow_id"], user_input={"next_step_id": "zigbee_intent_recommended"}, ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "install_zigbee_firmware" + assert result["progress_action"] == "install_firmware" + + await hass.async_block_till_done(wait_background_tasks=True) + + create_result = await hass.config_entries.options.async_configure( + result["flow_id"] + ) + assert create_result["type"] is FlowResultType.CREATE_ENTRY # The firmware type has been updated @@ -1094,7 +1076,6 @@ async def test_config_flow_zigbee_migrate_handler(hass: HomeAssistant) -> None: ) with mock_firmware_info( - hass, probe_app_type=ApplicationType.SPINEL, flash_app_type=ApplicationType.EZSP, ): @@ -1109,7 +1090,7 @@ async def test_config_flow_zigbee_migrate_handler(hass: HomeAssistant) -> None: assert result["step_id"] == "zigbee_installation_type" -@pytest.mark.usefixtures("addon_store_info") +@pytest.mark.usefixtures("addon_installed") async def test_config_flow_thread_migrate_handler(hass: HomeAssistant) -> None: """Test that the Thread migrate handler works correctly.""" # Ensure Thread migrate option is available by adding an OTBR entry @@ -1125,17 +1106,16 @@ async def test_config_flow_thread_migrate_handler(hass: HomeAssistant) -> None: ) with mock_firmware_info( - hass, probe_app_type=ApplicationType.EZSP, flash_app_type=ApplicationType.SPINEL, - ) as (_, _): + ): # Test the migrate handler directly result = await hass.config_entries.flow.async_configure( init_result["flow_id"], user_input={"next_step_id": "pick_firmware_thread_migrate"}, ) - # Should proceed to OTBR addon installation (same as normal thread flow) + # Should proceed to firmware install (same as normal thread flow) assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["progress_action"] == "install_addon" - assert result["step_id"] == "install_otbr_addon" + assert result["progress_action"] == "install_firmware" + assert result["step_id"] == "install_thread_firmware" diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index e02faf97cedb..217c331257e3 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch from aiohttp import ClientError import pytest -from homeassistant.components.hassio import AddonError, AddonInfo, AddonState +from homeassistant.components.hassio import AddonError from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, @@ -13,6 +13,7 @@ from homeassistant.components.homeassistant_hardware.firmware_config_flow import from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, + OwningAddon, OwningIntegration, ) from homeassistant.core import HomeAssistant @@ -44,7 +45,6 @@ async def test_config_flow_cannot_probe_firmware_zigbee(hass: HomeAssistant) -> """Test failure case when firmware cannot be probed for zigbee.""" with mock_firmware_info( - hass, probe_app_type=None, ): # Start the flow @@ -77,7 +77,7 @@ async def test_config_flow_cannot_probe_firmware_zigbee(hass: HomeAssistant) -> ["test_firmware_domain"], ) async def test_cannot_probe_after_install_zigbee(hass: HomeAssistant) -> None: - """Test unsupported firmware after install for Zigbee.""" + """Test unsupported firmware after firmware install for Zigbee.""" init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) @@ -86,7 +86,6 @@ async def test_cannot_probe_after_install_zigbee(hass: HomeAssistant) -> None: assert init_result["step_id"] == "pick_firmware" with mock_firmware_info( - hass, probe_app_type=ApplicationType.SPINEL, flash_app_type=ApplicationType.EZSP, ): @@ -109,7 +108,6 @@ async def test_cannot_probe_after_install_zigbee(hass: HomeAssistant) -> None: assert pick_result["step_id"] == "install_zigbee_firmware" with mock_firmware_info( - hass, probe_app_type=None, flash_app_type=ApplicationType.EZSP, ): @@ -132,7 +130,6 @@ async def test_config_flow_cannot_probe_firmware_thread(hass: HomeAssistant) -> """Test failure case when firmware cannot be probed for thread.""" with mock_firmware_info( - hass, probe_app_type=None, ): # Start the flow @@ -156,9 +153,9 @@ async def test_config_flow_cannot_probe_firmware_thread(hass: HomeAssistant) -> "ignore_translations_for_mock_domains", ["test_firmware_domain"], ) -@pytest.mark.usefixtures("addon_store_info") +@pytest.mark.usefixtures("addon_installed") async def test_cannot_probe_after_install_thread(hass: HomeAssistant) -> None: - """Test unsupported firmware after install for thread.""" + """Test unsupported firmware after firmware install for thread.""" init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) @@ -167,10 +164,9 @@ async def test_cannot_probe_after_install_thread(hass: HomeAssistant) -> None: assert init_result["step_id"] == "pick_firmware" with mock_firmware_info( - hass, probe_app_type=ApplicationType.EZSP, flash_app_type=ApplicationType.SPINEL, - ) as (mock_otbr_manager, _): + ): # Pick the menu option pick_result = await hass.config_entries.flow.async_configure( init_result["flow_id"], @@ -178,31 +174,14 @@ async def test_cannot_probe_after_install_thread(hass: HomeAssistant) -> None: ) assert pick_result["type"] is FlowResultType.SHOW_PROGRESS - assert pick_result["progress_action"] == "install_addon" - assert pick_result["step_id"] == "install_otbr_addon" + assert pick_result["progress_action"] == "install_firmware" + assert pick_result["step_id"] == "install_thread_firmware" description_placeholders = pick_result["description_placeholders"] assert description_placeholders is not None assert description_placeholders["firmware_type"] == "ezsp" assert description_placeholders["model"] == TEST_HARDWARE_NAME - await hass.async_block_till_done(wait_background_tasks=True) - - mock_otbr_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={ - "device": "", - "baudrate": 460800, - "flow_control": True, - "autoflash_firmware": False, - }, - state=AddonState.NOT_RUNNING, - update_available=False, - version="1.2.3", - ) - with mock_firmware_info( - hass, probe_app_type=None, flash_app_type=ApplicationType.SPINEL, ): @@ -232,15 +211,13 @@ async def test_config_flow_thread_not_hassio(hass: HomeAssistant) -> None: TEST_DOMAIN, context={"source": "hardware"} ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "pick_firmware" + with mock_firmware_info( - hass, is_hassio=False, probe_app_type=ApplicationType.EZSP, ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, @@ -253,20 +230,23 @@ async def test_config_flow_thread_not_hassio(hass: HomeAssistant) -> None: "ignore_translations_for_mock_domains", ["test_firmware_domain"], ) -async def test_config_flow_thread_addon_info_fails(hass: HomeAssistant) -> None: - """Test failure case when flasher addon cannot be installed.""" +async def test_config_flow_thread_addon_info_fails( + hass: HomeAssistant, + addon_store_info: AsyncMock, +) -> None: + """Test addon info fails before firmware install.""" + result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "pick_firmware" + with mock_firmware_info( - hass, probe_app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, _): - mock_otbr_manager.async_get_addon_info.side_effect = AddonError() - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) + ): + addon_store_info.side_effect = AddonError() result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, @@ -277,73 +257,75 @@ async def test_config_flow_thread_addon_info_fails(hass: HomeAssistant) -> None: assert result["reason"] == "addon_info_failed" +@pytest.mark.usefixtures("addon_not_installed") @pytest.mark.parametrize( "ignore_translations_for_mock_domains", ["test_firmware_domain"], ) -async def test_config_flow_thread_addon_install_fails(hass: HomeAssistant) -> None: +async def test_config_flow_thread_addon_install_fails( + hass: HomeAssistant, + install_addon: AsyncMock, +) -> None: """Test failure case when flasher addon cannot be installed.""" result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "pick_firmware" + with mock_firmware_info( - hass, probe_app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, _): - mock_otbr_manager.async_install_addon_waiting = AsyncMock( - side_effect=AddonError() - ) + ): + install_addon.side_effect = AddonError() - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "install_thread_firmware" + assert result["progress_action"] == "install_firmware" + + result = await consume_progress_flow( + hass, + flow_id=result["flow_id"], + valid_step_ids=( + "install_otbr_addon", + "install_thread_firmware", + ), + ) + # Cannot install addon assert result["type"] == FlowResultType.ABORT assert result["reason"] == "addon_install_failed" +@pytest.mark.usefixtures("addon_installed") @pytest.mark.parametrize( "ignore_translations_for_mock_domains", ["test_firmware_domain"], ) -async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) -> None: +async def test_config_flow_thread_addon_set_config_fails( + hass: HomeAssistant, + set_addon_options: AsyncMock, +) -> None: """Test failure case when flasher addon cannot be configured.""" init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" + with mock_firmware_info( - hass, probe_app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, _): - - async def install_addon() -> None: - mock_otbr_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={"device": TEST_DEVICE}, - state=AddonState.NOT_RUNNING, - update_available=False, - version="1.0.0", - ) - - mock_otbr_manager.async_install_addon_waiting = AsyncMock( - side_effect=install_addon - ) - mock_otbr_manager.async_set_addon_options = AsyncMock(side_effect=AddonError()) - - confirm_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], user_input={} - ) + ): + set_addon_options.side_effect = AddonError() pick_thread_result = await hass.config_entries.flow.async_configure( - confirm_result["flow_id"], + init_result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) @@ -361,36 +343,29 @@ async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) -> assert pick_thread_progress_result["reason"] == "addon_set_config_failed" +@pytest.mark.usefixtures("addon_installed") @pytest.mark.parametrize( "ignore_translations_for_mock_domains", ["test_firmware_domain"], ) -async def test_config_flow_thread_flasher_run_fails(hass: HomeAssistant) -> None: +async def test_config_flow_thread_flasher_run_fails( + hass: HomeAssistant, + start_addon: AsyncMock, +) -> None: """Test failure case when flasher addon fails to run.""" + start_addon.side_effect = AddonError() init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" + with mock_firmware_info( - hass, probe_app_type=ApplicationType.EZSP, - otbr_addon_info=AddonInfo( - available=True, - hostname=None, - options={"device": TEST_DEVICE}, - state=AddonState.NOT_RUNNING, - update_available=False, - version="1.0.0", - ), - ) as (mock_otbr_manager, _): - mock_otbr_manager.async_start_addon_waiting = AsyncMock( - side_effect=AddonError() - ) - confirm_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], user_input={} - ) + ): pick_thread_result = await hass.config_entries.flow.async_configure( - confirm_result["flow_id"], + init_result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) @@ -408,6 +383,7 @@ async def test_config_flow_thread_flasher_run_fails(hass: HomeAssistant) -> None assert pick_thread_progress_result["reason"] == "addon_start_failed" +@pytest.mark.usefixtures("addon_running") @pytest.mark.parametrize( "ignore_translations_for_mock_domains", ["test_firmware_domain"], @@ -418,24 +394,15 @@ async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> Non TEST_DOMAIN, context={"source": "hardware"} ) + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" + with mock_firmware_info( - hass, probe_app_type=ApplicationType.EZSP, flash_app_type=None, - otbr_addon_info=AddonInfo( - available=True, - hostname=None, - options={"device": TEST_DEVICE}, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ), ): - confirm_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], user_input={} - ) pick_thread_result = await hass.config_entries.flow.async_configure( - confirm_result["flow_id"], + init_result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) @@ -467,13 +434,10 @@ async def test_config_flow_firmware_index_download_fails_and_required( assert init_result["type"] is FlowResultType.MENU assert init_result["step_id"] == "pick_firmware" - with ( - mock_firmware_info( - hass, - # The wrong firmware is installed, so a new install is required - probe_app_type=ApplicationType.SPINEL, - ) as (_, mock_update_client), - ): + with mock_firmware_info( + # The wrong firmware is installed, so a new install is required + probe_app_type=ApplicationType.SPINEL, + ) as mock_update_client: mock_update_client.async_update_data.side_effect = ClientError() pick_result = await hass.config_entries.flow.async_configure( @@ -507,13 +471,10 @@ async def test_config_flow_firmware_download_fails_and_required( assert init_result["type"] is FlowResultType.MENU assert init_result["step_id"] == "pick_firmware" - with ( - mock_firmware_info( - hass, - # The wrong firmware is installed, so a new install is required - probe_app_type=ApplicationType.SPINEL, - ) as (_, mock_update_client), - ): + with mock_firmware_info( + # The wrong firmware is installed, so a new install is required + probe_app_type=ApplicationType.SPINEL, + ) as mock_update_client: mock_update_client.async_fetch_firmware.side_effect = ClientError() pick_result = await hass.config_entries.flow.async_configure( @@ -585,7 +546,6 @@ async def test_options_flow_zigbee_to_thread_zha_configured( "ignore_translations_for_mock_domains", ["test_firmware_domain"], ) -@pytest.mark.usefixtures("addon_store_info") async def test_options_flow_thread_to_zigbee_otbr_configured( hass: HomeAssistant, ) -> None: @@ -607,21 +567,23 @@ async def test_options_flow_thread_to_zigbee_otbr_configured( # Confirm options flow result = await hass.config_entries.options.async_init(config_entry.entry_id) - with mock_firmware_info( - hass, - probe_app_type=ApplicationType.SPINEL, - otbr_addon_info=AddonInfo( - available=True, - hostname=None, - options={"device": TEST_DEVICE}, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ), + # Pretend OTBR is using the stick + with patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.guess_hardware_owners", + return_value=[ + FirmwareInfo( + device=TEST_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version="1.2.3.4", + source="otbr", + owners=[OwningAddon(slug="openthread_border_router")], + ) + ], ): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "otbr_still_using_stick" + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "otbr_still_using_stick" diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index d9b98966f1db..2b863450d7df 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -1,6 +1,7 @@ """Test the Home Assistant SkyConnect config flow.""" -from unittest.mock import Mock, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, call, patch import pytest @@ -29,6 +30,16 @@ from .common import USB_DATA_SKY, USB_DATA_ZBT1 from tests.common import MockConfigEntry +@pytest.fixture(name="supervisor") +def mock_supervisor_fixture() -> Generator[None]: + """Mock Supervisor.""" + with patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.is_hassio", + return_value=True, + ): + yield + + @pytest.mark.parametrize( ("usb_data", "model"), [ @@ -70,16 +81,9 @@ async def test_config_flow_zigbee( step_id: str, next_step_id: str, ) -> ConfigFlowResult: - if next_step_id == "start_otbr_addon": - next_step_id = "pre_confirm_otbr" - - return await getattr(self, f"async_step_{next_step_id}")(user_input={}) + return await getattr(self, f"async_step_{next_step_id}")() with ( - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._ensure_thread_addon_setup", - return_value=None, - ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._install_firmware_step", autospec=True, @@ -133,6 +137,7 @@ async def test_config_flow_zigbee( assert zha_flow["step_id"] == "confirm" +@pytest.mark.usefixtures("addon_installed", "supervisor") @pytest.mark.parametrize( ("usb_data", "model"), [ @@ -150,6 +155,7 @@ async def test_config_flow_thread( usb_data: UsbServiceInfo, model: str, hass: HomeAssistant, + start_addon: AsyncMock, ) -> None: """Test the config flow for SkyConnect with Thread.""" fw_type = ApplicationType.SPINEL @@ -174,16 +180,9 @@ async def test_config_flow_thread( step_id: str, next_step_id: str, ) -> ConfigFlowResult: - if next_step_id == "start_otbr_addon": - next_step_id = "pre_confirm_otbr" - - return await getattr(self, f"async_step_{next_step_id}")(user_input={}) + return await getattr(self, f"async_step_{next_step_id}")() with ( - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._ensure_thread_addon_setup", - return_value=None, - ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._install_firmware_step", autospec=True, @@ -200,11 +199,23 @@ async def test_config_flow_thread( ), ), ): - confirm_result = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_otbr_addon" + + # Make sure the flow continues when the progress task is done. + await hass.async_block_till_done() + + confirm_result = await hass.config_entries.flow.async_configure( + result["flow_id"] + ) + + assert start_addon.call_count == 1 + assert start_addon.call_args == call("core_openthread_border_router") assert confirm_result["type"] is FlowResultType.FORM assert confirm_result["step_id"] == ("confirm_otbr") @@ -279,20 +290,13 @@ async def test_options_flow( step_id: str, next_step_id: str, ) -> ConfigFlowResult: - if next_step_id == "start_otbr_addon": - next_step_id = "pre_confirm_otbr" - - return await getattr(self, f"async_step_{next_step_id}")(user_input={}) + return await getattr(self, f"async_step_{next_step_id}")() with ( patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.guess_hardware_owners", return_value=[], ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow._ensure_thread_addon_setup", - return_value=None, - ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow._install_firmware_step", autospec=True, diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 815163ce2069..518a1d3b4d19 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -1,7 +1,7 @@ """Test the Home Assistant Yellow config flow.""" from collections.abc import Generator -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock, call, patch import pytest @@ -352,20 +352,13 @@ async def test_firmware_options_flow_zigbee(hass: HomeAssistant) -> None: step_id: str, next_step_id: str, ) -> ConfigFlowResult: - if next_step_id == "start_otbr_addon": - next_step_id = "pre_confirm_otbr" - - return await getattr(self, f"async_step_{next_step_id}")(user_input={}) + return await getattr(self, f"async_step_{next_step_id}")() with ( patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.guess_hardware_owners", return_value=[], ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareInstallFlow._ensure_thread_addon_setup", - return_value=None, - ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareInstallFlow._install_firmware_step", autospec=True, @@ -403,8 +396,10 @@ async def test_firmware_options_flow_zigbee(hass: HomeAssistant) -> None: } -@pytest.mark.usefixtures("addon_store_info") -async def test_firmware_options_flow_thread(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("addon_installed") +async def test_firmware_options_flow_thread( + hass: HomeAssistant, start_addon: AsyncMock +) -> None: """Test the firmware options flow for Yellow with Thread.""" fw_type = ApplicationType.SPINEL fw_version = "2.4.4.0" @@ -448,20 +443,13 @@ async def test_firmware_options_flow_thread(hass: HomeAssistant) -> None: step_id: str, next_step_id: str, ) -> ConfigFlowResult: - if next_step_id == "start_otbr_addon": - next_step_id = "pre_confirm_otbr" - - return await getattr(self, f"async_step_{next_step_id}")(user_input={}) + return await getattr(self, f"async_step_{next_step_id}")() with ( patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.guess_hardware_owners", return_value=[], ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareInstallFlow._ensure_thread_addon_setup", - return_value=None, - ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareInstallFlow._install_firmware_step", autospec=True, @@ -478,11 +466,23 @@ async def test_firmware_options_flow_thread(hass: HomeAssistant) -> None: ), ), ): - confirm_result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_otbr_addon" + + # Make sure the flow continues when the progress task is done. + await hass.async_block_till_done() + + confirm_result = await hass.config_entries.options.async_configure( + result["flow_id"] + ) + + assert start_addon.call_count == 1 + assert start_addon.call_args == call("core_openthread_border_router") assert confirm_result["type"] is FlowResultType.FORM assert confirm_result["step_id"] == ("confirm_otbr") From 00b201776776ccebc212d19de51ade9fe50fad69 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 23 Sep 2025 11:59:00 +0200 Subject: [PATCH 046/189] Fix resource and payload template in scrape (#152670) --- homeassistant/components/scrape/__init__.py | 5 ++- .../components/scrape/coordinator.py | 13 ++++++ tests/components/scrape/test_init.py | 41 +++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index 27ee3854f927..5c39b57f785a 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -78,7 +78,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: scan_interval: timedelta = resource_config.get( CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL ) - coordinator = ScrapeCoordinator(hass, None, rest, scan_interval) + coordinator = ScrapeCoordinator( + hass, None, rest, resource_config, scan_interval + ) sensors: list[ConfigType] = resource_config.get(SENSOR_DOMAIN, []) if sensors: @@ -108,6 +110,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bo hass, entry, rest, + rest_config, DEFAULT_SCAN_INTERVAL, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/scrape/coordinator.py b/homeassistant/components/scrape/coordinator.py index b5cabc6b94e7..07566c968f17 100644 --- a/homeassistant/components/scrape/coordinator.py +++ b/homeassistant/components/scrape/coordinator.py @@ -4,11 +4,14 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import Any from bs4 import BeautifulSoup from homeassistant.components.rest import RestData +from homeassistant.components.rest.const import CONF_PAYLOAD_TEMPLATE from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_RESOURCE_TEMPLATE from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -23,6 +26,7 @@ class ScrapeCoordinator(DataUpdateCoordinator[BeautifulSoup]): hass: HomeAssistant, config_entry: ConfigEntry | None, rest: RestData, + rest_config: dict[str, Any], update_interval: timedelta, ) -> None: """Initialize Scrape coordinator.""" @@ -34,9 +38,18 @@ class ScrapeCoordinator(DataUpdateCoordinator[BeautifulSoup]): update_interval=update_interval, ) self._rest = rest + self._rest_config = rest_config async def _async_update_data(self) -> BeautifulSoup: """Fetch data from Rest.""" + if CONF_RESOURCE_TEMPLATE in self._rest_config: + self._rest.set_url( + self._rest_config["resource_template"].async_render(parse_result=False) + ) + if CONF_PAYLOAD_TEMPLATE in self._rest_config: + self._rest.set_payload( + self._rest_config["payload_template"].async_render(parse_result=False) + ) await self._rest.async_update() if (data := self._rest.data) is None: raise UpdateFailed("REST data is not available") diff --git a/tests/components/scrape/test_init.py b/tests/components/scrape/test_init.py index 363e30b92696..088ecc182eea 100644 --- a/tests/components/scrape/test_init.py +++ b/tests/components/scrape/test_init.py @@ -2,8 +2,10 @@ from __future__ import annotations +from http import HTTPStatus from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.scrape.const import DEFAULT_SCAN_INTERVAL, DOMAIN @@ -16,6 +18,7 @@ from homeassistant.util import dt as dt_util from . import MockRestData, return_integration_config from tests.common import MockConfigEntry, async_fire_time_changed +from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator @@ -152,3 +155,41 @@ async def test_device_remove_devices( ) response = await client.remove_device(dead_device_entry.id, loaded_entry.entry_id) assert response["success"] + + +async def test_resource_template( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, +) -> None: + """Test resource_template is evaluated on each scan.""" + hass.states.async_set("sensor.input_sensor", "localhost") + aioclient_mock.get( + "http://localhost", + status=HTTPStatus.OK, + text="

First

", + ) + aioclient_mock.get( + "http://localhost2", + status=HTTPStatus.OK, + text="

Second

", + ) + + config = { + DOMAIN: { + "resource_template": "http://{{ states.sensor.input_sensor.state }}", + "verify_ssl": True, + "sensor": [{"select": "h1", "name": "template sensor"}], + } + } + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get("sensor.template_sensor") + assert state.state == "First" + + hass.states.async_set("sensor.input_sensor", "localhost2") + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get("sensor.template_sensor") + assert state.state == "Second" From 52c25cfc8853c205995f4675c03f527d3b197bea Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 23 Sep 2025 12:14:05 +0200 Subject: [PATCH 047/189] Rename modbus internal variable (#152805) --- .../components/modbus/binary_sensor.py | 2 +- homeassistant/components/modbus/climate.py | 30 ++++++++++--------- homeassistant/components/modbus/cover.py | 12 ++++++-- homeassistant/components/modbus/entity.py | 10 +++---- homeassistant/components/modbus/light.py | 8 ++--- homeassistant/components/modbus/sensor.py | 2 +- 6 files changed, 36 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index e342347cbf93..c230cfc5379e 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -106,7 +106,7 @@ class ModbusBinarySensor(ModbusBaseEntity, RestoreEntity, BinarySensorEntity): # do not allow multiple active calls to the same platform result = await self._hub.async_pb_call( - self._slave, self._address, self._count, self._input_type + self._device_address, self._address, self._count, self._input_type ) if result is None: self._attr_available = False diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index f886a308f099..a99a8839ba30 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -315,7 +315,7 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity): # register, or self._hvac_on_value otherwise. if self._hvac_onoff_write_registers: await self._hub.async_pb_call( - self._slave, + self._device_address, self._hvac_onoff_register, [ self._hvac_off_value @@ -326,7 +326,7 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity): ) else: await self._hub.async_pb_call( - self._slave, + self._device_address, self._hvac_onoff_register, self._hvac_off_value if hvac_mode == HVACMode.OFF @@ -337,7 +337,7 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity): if self._hvac_onoff_coil is not None: # Turn HVAC Off by writing 0 to the On/Off coil, or 1 otherwise. await self._hub.async_pb_call( - self._slave, + self._device_address, self._hvac_onoff_coil, 0 if hvac_mode == HVACMode.OFF else 1, CALL_TYPE_WRITE_COIL, @@ -349,14 +349,14 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity): if mode == hvac_mode: if self._hvac_mode_write_registers: await self._hub.async_pb_call( - self._slave, + self._device_address, self._hvac_mode_register, [value], CALL_TYPE_WRITE_REGISTERS, ) else: await self._hub.async_pb_call( - self._slave, + self._device_address, self._hvac_mode_register, value, CALL_TYPE_WRITE_REGISTER, @@ -372,14 +372,14 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity): value = self._fan_mode_mapping_to_modbus[fan_mode] if isinstance(self._fan_mode_register, list): await self._hub.async_pb_call( - self._slave, + self._device_address, self._fan_mode_register[0], [value], CALL_TYPE_WRITE_REGISTERS, ) else: await self._hub.async_pb_call( - self._slave, + self._device_address, self._fan_mode_register, value, CALL_TYPE_WRITE_REGISTER, @@ -395,14 +395,14 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity): if swing_mode == smode: if isinstance(self._swing_mode_register, list): await self._hub.async_pb_call( - self._slave, + self._device_address, self._swing_mode_register[0], [value], CALL_TYPE_WRITE_REGISTERS, ) else: await self._hub.async_pb_call( - self._slave, + self._device_address, self._swing_mode_register, value, CALL_TYPE_WRITE_REGISTER, @@ -437,7 +437,7 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity): ): if self._target_temperature_write_registers: result = await self._hub.async_pb_call( - self._slave, + self._device_address, self._target_temperature_register[ HVACMODE_TO_TARG_TEMP_REG_INDEX_ARRAY[self._attr_hvac_mode] ], @@ -446,7 +446,7 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity): ) else: result = await self._hub.async_pb_call( - self._slave, + self._device_address, self._target_temperature_register[ HVACMODE_TO_TARG_TEMP_REG_INDEX_ARRAY[self._attr_hvac_mode] ], @@ -455,7 +455,7 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity): ) else: result = await self._hub.async_pb_call( - self._slave, + self._device_address, self._target_temperature_register[ HVACMODE_TO_TARG_TEMP_REG_INDEX_ARRAY[self._attr_hvac_mode] ], @@ -566,7 +566,7 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity): ) -> float | None: """Read register using the Modbus hub slave.""" result = await self._hub.async_pb_call( - self._slave, register, self._count, register_type + self._device_address, register, self._count, register_type ) if result is None: self._attr_available = False @@ -587,7 +587,9 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity): return float(self._value) async def _async_read_coil(self, address: int) -> int | None: - result = await self._hub.async_pb_call(self._slave, address, 1, CALL_TYPE_COIL) + result = await self._hub.async_pb_call( + self._device_address, address, 1, CALL_TYPE_COIL + ) if result is not None and result.bits is not None: self._attr_available = True return int(result.bits[0]) diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 9d4ebc9ebf05..21d04d2ffc4b 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -108,7 +108,10 @@ class ModbusCover(ModbusBaseEntity, CoverEntity, RestoreEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open cover.""" result = await self._hub.async_pb_call( - self._slave, self._write_address, self._state_open, self._write_type + self._device_address, + self._write_address, + self._state_open, + self._write_type, ) self._attr_available = result is not None await self.async_local_update(cancel_pending_update=True) @@ -116,7 +119,10 @@ class ModbusCover(ModbusBaseEntity, CoverEntity, RestoreEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" result = await self._hub.async_pb_call( - self._slave, self._write_address, self._state_closed, self._write_type + self._device_address, + self._write_address, + self._state_closed, + self._write_type, ) self._attr_available = result is not None await self.async_local_update(cancel_pending_update=True) @@ -124,7 +130,7 @@ class ModbusCover(ModbusBaseEntity, CoverEntity, RestoreEntity): async def _async_update(self) -> None: """Update the state of the cover.""" result = await self._hub.async_pb_call( - self._slave, self._address, 1, self._input_type + self._device_address, self._address, 1, self._input_type ) if result is None: self._attr_available = False diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py index 437d0aaf93fb..5a25870512e1 100644 --- a/homeassistant/components/modbus/entity.py +++ b/homeassistant/components/modbus/entity.py @@ -83,9 +83,9 @@ class ModbusBaseEntity(Entity): self._hub = hub if (conf_slave := entry.get(CONF_SLAVE)) is not None: - self._slave = conf_slave + self._device_address = conf_slave else: - self._slave = entry.get(CONF_DEVICE_ADDRESS, 1) + self._device_address = entry.get(CONF_DEVICE_ADDRESS, 1) self._address = int(entry[CONF_ADDRESS]) self._input_type = entry[CONF_INPUT_TYPE] self._scan_interval = int(entry[CONF_SCAN_INTERVAL]) @@ -323,7 +323,7 @@ class ModbusToggleEntity(ModbusBaseEntity, ToggleEntity, RestoreEntity): async def async_turn(self, command: int) -> None: """Evaluate switch result.""" result = await self._hub.async_pb_call( - self._slave, self._address, command, self._write_type + self._device_address, self._address, command, self._write_type ) if result is None: self._attr_available = False @@ -358,7 +358,7 @@ class ModbusToggleEntity(ModbusBaseEntity, ToggleEntity, RestoreEntity): # do not allow multiple active calls to the same platform result = await self._hub.async_pb_call( - self._slave, self._verify_address, 1, self._verify_type + self._device_address, self._verify_address, 1, self._verify_type ) if result is None: self._attr_available = False @@ -379,7 +379,7 @@ class ModbusToggleEntity(ModbusBaseEntity, ToggleEntity, RestoreEntity): "Unexpected response from modbus device slave %s register %s," " got 0x%2x" ), - self._slave, + self._device_address, self._verify_address, value, ) diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index 6e7d2048279a..4c27ffb456b6 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -117,7 +117,7 @@ class ModbusLight(ModbusToggleEntity, LightEntity): conv_brightness = self._convert_brightness_to_modbus(brightness) await self._hub.async_pb_call( - unit=self._slave, + unit=self._device_address, address=self._brightness_address, value=conv_brightness, use_call=CALL_TYPE_WRITE_REGISTER, @@ -133,7 +133,7 @@ class ModbusLight(ModbusToggleEntity, LightEntity): conv_color_temp_kelvin = self._convert_color_temp_to_modbus(color_temp_kelvin) await self._hub.async_pb_call( - unit=self._slave, + unit=self._device_address, address=self._color_temp_address, value=conv_color_temp_kelvin, use_call=CALL_TYPE_WRITE_REGISTER, @@ -150,7 +150,7 @@ class ModbusLight(ModbusToggleEntity, LightEntity): if self._brightness_address: brightness_result = await self._hub.async_pb_call( - unit=self._slave, + unit=self._device_address, value=1, address=self._brightness_address, use_call=CALL_TYPE_REGISTER_HOLDING, @@ -167,7 +167,7 @@ class ModbusLight(ModbusToggleEntity, LightEntity): if self._color_temp_address: color_result = await self._hub.async_pb_call( - unit=self._slave, + unit=self._device_address, value=1, address=self._color_temp_address, use_call=CALL_TYPE_REGISTER_HOLDING, diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index a61fdfb32bd7..185d336cc6a9 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -107,7 +107,7 @@ class ModbusRegisterSensor(ModbusStructEntity, RestoreSensor, SensorEntity): async def _async_update(self) -> None: """Update the state of the sensor.""" raw_result = await self._hub.async_pb_call( - self._slave, self._address, self._count, self._input_type + self._device_address, self._address, self._count, self._input_type ) if raw_result is None: self._attr_available = False From 689039959c3fd139f395ac8e9fd44becacdcbba8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 23 Sep 2025 12:38:47 +0200 Subject: [PATCH 048/189] Improve current_state support in Tuya curtains (#152801) --- homeassistant/components/tuya/cover.py | 22 +++++++++---------- .../components/tuya/snapshots/test_cover.ambr | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 1de952501c53..be75ff9d6944 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -30,7 +30,7 @@ from .util import get_dpcode class TuyaCoverEntityDescription(CoverEntityDescription): """Describe an Tuya cover entity.""" - current_state: DPCode | None = None + current_state: DPCode | tuple[DPCode, ...] | None = None current_state_inverse: bool = False current_position: DPCode | tuple[DPCode, ...] | None = None set_position: DPCode | None = None @@ -76,7 +76,7 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { TuyaCoverEntityDescription( key=DPCode.CONTROL, translation_key="curtain", - current_state=DPCode.SITUATION_SET, + current_state=(DPCode.SITUATION_SET, DPCode.CONTROL), current_position=(DPCode.PERCENT_STATE, DPCode.PERCENT_CONTROL), set_position=DPCode.PERCENT_CONTROL, device_class=CoverDeviceClass.CURTAIN, @@ -189,6 +189,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): """Tuya Cover Device.""" _current_position: IntegerTypeData | None = None + _current_state: DPCode | None = None _set_position: IntegerTypeData | None = None _tilt: IntegerTypeData | None = None _motor_reverse_mode_enum: EnumTypeData | None = None @@ -222,6 +223,8 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): if description.stop_instruction_value in enum_type.range: self._attr_supported_features |= CoverEntityFeature.STOP + self._current_state = get_dpcode(self.device, description.current_state) + # Determine type to use for setting the position if int_type := self.find_dpcode( description.set_position, dptype=DPType.INTEGER, prefer_function=True @@ -299,22 +302,19 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): @property def is_closed(self) -> bool | None: """Return true if cover is closed.""" + # If it's available, prefer the position over the current state + if (position := self.current_cover_position) is not None: + return position == 0 + if ( - self.entity_description.current_state is not None - and ( - current_state := self.device.status.get( - self.entity_description.current_state - ) - ) + self._current_state is not None + and (current_state := self.device.status.get(self._current_state)) is not None ): return self.entity_description.current_state_inverse is not ( current_state in (True, "fully_close") ) - if (position := self.current_cover_position) is not None: - return position == 0 - return None def open_cover(self, **kwargs: Any) -> None: diff --git a/tests/components/tuya/snapshots/test_cover.ambr b/tests/components/tuya/snapshots/test_cover.ambr index e47af2155c44..582ef64ff3f6 100644 --- a/tests/components/tuya/snapshots/test_cover.ambr +++ b/tests/components/tuya/snapshots/test_cover.ambr @@ -198,7 +198,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'open', }) # --- # name: test_platform_setup_and_discovery[cover.kitchen_blinds_blind-entry] From abbf8390ac0c695043ba369070c5131ef24b841f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 23 Sep 2025 12:39:37 +0200 Subject: [PATCH 049/189] Move switch to valve for Tuya sfkzq category (#152478) --- homeassistant/components/tuya/strings.json | 9 + homeassistant/components/tuya/switch.py | 79 +++++- homeassistant/components/tuya/valve.py | 5 + .../tuya/snapshots/test_switch.ambr | 240 ----------------- .../components/tuya/snapshots/test_valve.ambr | 250 ++++++++++++++++++ tests/components/tuya/test_switch.py | 56 +++- tests/components/tuya/test_valve.py | 2 + 7 files changed, 398 insertions(+), 243 deletions(-) diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 816827d991d4..b5b543f4fa3d 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -1023,6 +1023,9 @@ } }, "valve": { + "valve": { + "name": "Valve" + }, "indexed_valve": { "name": "Valve {index}" } @@ -1032,5 +1035,11 @@ "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})." } + }, + "issues": { + "deprecated_entity_new_valve": { + "title": "{name} is deprecated", + "description": "The Tuya entity `{entity}` is deprecated, replaced by a new valve entity.\nPlease update your dashboards, automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue." + } } } diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 208cd3e19b7b..f5324888d818 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -2,24 +2,41 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any from tuya_sharing import CustomerDevice, Manager from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, SwitchDeviceClass, SwitchEntity, SwitchEntityDescription, ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from . import TuyaConfigEntry -from .const import TUYA_DISCOVERY_NEW, DPCode +from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode from .entity import TuyaEntity + +@dataclass(frozen=True, kw_only=True) +class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): + """Describes Tuya deprecated switch entity.""" + + deprecated: str + breaks_in_ha_version: str + + # All descriptions can be found here. Mostly the Boolean data types in the # default instruction set of each category end up being a Switch. # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq @@ -673,9 +690,11 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), # Smart Water Timer "sfkzq": ( - SwitchEntityDescription( + TuyaDeprecatedSwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", + deprecated="deprecated_entity_new_valve", + breaks_in_ha_version="2026.4.0", ), ), # Siren Alarm @@ -981,6 +1000,7 @@ async def async_setup_entry( ) -> None: """Set up tuya sensors dynamically through tuya discovery.""" hass_data = entry.runtime_data + entity_registry = er.async_get(hass) @callback def async_discover_device(device_ids: list[str]) -> None: @@ -993,6 +1013,12 @@ async def async_setup_entry( TuyaSwitchEntity(device, hass_data.manager, description) for description in descriptions if description.key in device.status + and _check_deprecation( + hass, + device, + description, + entity_registry, + ) ) async_add_entities(entities) @@ -1004,6 +1030,55 @@ async def async_setup_entry( ) +def _check_deprecation( + hass: HomeAssistant, + device: CustomerDevice, + description: SwitchEntityDescription, + entity_registry: er.EntityRegistry, +) -> bool: + """Check entity deprecation. + + Returns: + `True` if the entity should be created, `False` otherwise. + """ + # Not deprecated, just create it + if not isinstance(description, TuyaDeprecatedSwitchEntityDescription): + return True + + unique_id = f"tuya.{device.id}{description.key}" + entity_id = entity_registry.async_get_entity_id(SWITCH_DOMAIN, DOMAIN, unique_id) + + # Deprecated and not present in registry, skip creation + if not entity_id or not (entity_entry := entity_registry.async_get(entity_id)): + return False + + # Deprecated and present in registry but disabled, remove it and skip creation + if entity_entry.disabled: + entity_registry.async_remove(entity_id) + async_delete_issue( + hass, + DOMAIN, + f"deprecated_entity_{unique_id}", + ) + return False + + # Deprecated and present in registry and enabled, raise issue and create it + async_create_issue( + hass, + DOMAIN, + f"deprecated_entity_{unique_id}", + breaks_in_ha_version=description.breaks_in_ha_version, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=description.deprecated, + translation_placeholders={ + "name": f"{device.name} {entity_entry.name or entity_entry.original_name}", + "entity": entity_id, + }, + ) + return True + + class TuyaSwitchEntity(TuyaEntity, SwitchEntity): """Tuya Switch Device.""" diff --git a/homeassistant/components/tuya/valve.py b/homeassistant/components/tuya/valve.py index 06218c7030fb..42d4556a0d02 100644 --- a/homeassistant/components/tuya/valve.py +++ b/homeassistant/components/tuya/valve.py @@ -24,6 +24,11 @@ from .entity import TuyaEntity VALVES: dict[str, tuple[ValveEntityDescription, ...]] = { # Smart Water Timer "sfkzq": ( + ValveEntityDescription( + key=DPCode.SWITCH, + translation_key="valve", + device_class=ValveDeviceClass.WATER, + ), ValveEntityDescription( key=DPCode.SWITCH_1, translation_key="indexed_valve", diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index eb12e64fe42c..07c223cd6152 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -826,54 +826,6 @@ '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({ @@ -4209,54 +4161,6 @@ 'state': 'on', }) # --- -# name: test_platform_setup_and_discovery[switch.garden_valve_yard_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.garden_valve_yard_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.ggimpv4dqzkfsswitch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[switch.garden_valve_yard_switch-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Garden Valve Yard Switch', - }), - 'context': , - 'entity_id': 'switch.garden_valve_yard_switch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_platform_setup_and_discovery[switch.hl400_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7794,54 +7698,6 @@ 'state': 'on', }) # --- -# name: test_platform_setup_and_discovery[switch.smart_water_timer_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_water_timer_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.bl5cuqxnqzkfsswitch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[switch.smart_water_timer_switch-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Smart Water Timer Switch', - }), - 'context': , - 'entity_id': 'switch.smart_water_timer_switch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- # name: test_platform_setup_and_discovery[switch.smart_white_noise_machine-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -8521,54 +8377,6 @@ 'state': 'off', }) # --- -# 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.steckdose_2_socket-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -9160,54 +8968,6 @@ '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.varmelampa_socket_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_valve.ambr b/tests/components/tuya/snapshots/test_valve.ambr index cb5f78a56106..55d42dc56a2f 100644 --- a/tests/components/tuya/snapshots/test_valve.ambr +++ b/tests/components/tuya/snapshots/test_valve.ambr @@ -1,4 +1,104 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[valve.balkonbewasserung_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': 'valve', + 'entity_category': None, + 'entity_id': 'valve.balkonbewasserung_valve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'valve', + 'unique_id': 'tuya.73ov8i8iedtylkzrqzkfsswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.balkonbewasserung_valve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'balkonbewässerung Valve', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.balkonbewasserung_valve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_platform_setup_and_discovery[valve.garden_valve_yard_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': 'valve', + 'entity_category': None, + 'entity_id': 'valve.garden_valve_yard_valve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'valve', + 'unique_id': 'tuya.ggimpv4dqzkfsswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.garden_valve_yard_valve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Garden Valve Yard Valve', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.garden_valve_yard_valve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- # name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -399,3 +499,153 @@ 'state': 'closed', }) # --- +# name: test_platform_setup_and_discovery[valve.smart_water_timer_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': 'valve', + 'entity_category': None, + 'entity_id': 'valve.smart_water_timer_valve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'valve', + 'unique_id': 'tuya.bl5cuqxnqzkfsswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.smart_water_timer_valve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Smart Water Timer Valve', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.smart_water_timer_valve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[valve.sprinkler_cesare_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': 'valve', + 'entity_category': None, + 'entity_id': 'valve.sprinkler_cesare_valve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'valve', + 'unique_id': 'tuya.tskafaotnfigad6oqzkfsswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.sprinkler_cesare_valve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Sprinkler Cesare Valve', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.sprinkler_cesare_valve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_platform_setup_and_discovery[valve.valve_controller_2_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': 'valve', + 'entity_category': None, + 'entity_id': 'valve.valve_controller_2_valve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'valve', + 'unique_id': 'tuya.kx8dncf1qzkfsswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.valve_controller_2_valve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Valve Controller 2 Valve', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.valve_controller_2_valve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/tuya/test_switch.py b/tests/components/tuya/test_switch.py index 20138b7f0f2f..6124c54b5a99 100644 --- a/tests/components/tuya/test_switch.py +++ b/tests/components/tuya/test_switch.py @@ -4,12 +4,15 @@ from __future__ import annotations from unittest.mock import patch +import pytest from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice, Manager +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.tuya import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from . import initialize_entry @@ -29,3 +32,54 @@ async def 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( + ("preexisting_entity", "disabled_by", "expected_entity", "expected_issue"), + [ + (True, None, True, True), + (True, er.RegistryEntryDisabler.USER, False, False), + (False, None, False, False), + ], +) +@pytest.mark.parametrize( + "mock_device_code", + ["sfkzq_rzklytdei8i8vo37"], +) +async def test_sfkzq_deprecated_switch( + hass: HomeAssistant, + mock_manager: Manager, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, + preexisting_entity: bool, + disabled_by: er.RegistryEntryDisabler, + expected_entity: bool, + expected_issue: bool, +) -> None: + """Test switch deprecation issue.""" + original_entity_id = "switch.balkonbewasserung_switch" + entity_unique_id = "tuya.73ov8i8iedtylkzrqzkfsswitch" + if preexisting_entity: + suggested_id = original_entity_id.replace(f"{SWITCH_DOMAIN}.", "") + entity_registry.async_get_or_create( + SWITCH_DOMAIN, + DOMAIN, + entity_unique_id, + suggested_object_id=suggested_id, + disabled_by=disabled_by, + ) + + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert ( + entity_registry.async_get(original_entity_id) is not None + ) is expected_entity + assert ( + issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"deprecated_entity_{entity_unique_id}", + ) + is not None + ) is expected_issue diff --git a/tests/components/tuya/test_valve.py b/tests/components/tuya/test_valve.py index b532bacffa87..9f2c402500d1 100644 --- a/tests/components/tuya/test_valve.py +++ b/tests/components/tuya/test_valve.py @@ -37,6 +37,7 @@ async def test_platform_setup_and_discovery( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.VALVE]) @pytest.mark.parametrize( "mock_device_code", ["sfkzq_ed7frwissyqrejic"], @@ -66,6 +67,7 @@ async def test_open_valve( ) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.VALVE]) @pytest.mark.parametrize( "mock_device_code", ["sfkzq_ed7frwissyqrejic"], From dd3e6b8df527bb2df9987440081545ec88f09567 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 23 Sep 2025 12:41:31 +0200 Subject: [PATCH 050/189] Only load selected processes in systemmonitor (#152777) --- .../components/systemmonitor/binary_sensor.py | 12 ++------ .../components/systemmonitor/const.py | 4 +++ .../components/systemmonitor/coordinator.py | 30 +++++++++++++++++-- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/systemmonitor/binary_sensor.py b/homeassistant/components/systemmonitor/binary_sensor.py index 3968e94ec03a..ad84e727129a 100644 --- a/homeassistant/components/systemmonitor/binary_sensor.py +++ b/homeassistant/components/systemmonitor/binary_sensor.py @@ -9,8 +9,6 @@ import logging import sys from typing import Literal -from psutil import NoSuchProcess - from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, @@ -25,7 +23,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify from . import SystemMonitorConfigEntry -from .const import CONF_PROCESS, DOMAIN +from .const import CONF_PROCESS, DOMAIN, PROCESS_ERRORS from .coordinator import SystemMonitorCoordinator _LOGGER = logging.getLogger(__name__) @@ -59,12 +57,8 @@ def get_process(entity: SystemMonitorSensor) -> bool: if entity.argument == proc.name(): state = True break - except NoSuchProcess as err: - _LOGGER.warning( - "Failed to load process with ID: %s, old name: %s", - err.pid, - err.name, - ) + except PROCESS_ERRORS: + continue return state diff --git a/homeassistant/components/systemmonitor/const.py b/homeassistant/components/systemmonitor/const.py index 798cb82f8eff..72fd3384687c 100644 --- a/homeassistant/components/systemmonitor/const.py +++ b/homeassistant/components/systemmonitor/const.py @@ -1,5 +1,7 @@ """Constants for System Monitor.""" +from psutil import AccessDenied, Error, NoSuchProcess, TimeoutExpired, ZombieProcess + DOMAIN = "systemmonitor" CONF_INDEX = "index" @@ -14,6 +16,8 @@ NET_IO_TYPES = [ "packets_out", ] +PROCESS_ERRORS = (NoSuchProcess, AccessDenied, Error, TimeoutExpired, ZombieProcess) + # There might be additional keys to be added for different # platforms / hardware combinations. # Taken from last version of "glances" integration before they moved to diff --git a/homeassistant/components/systemmonitor/coordinator.py b/homeassistant/components/systemmonitor/coordinator.py index 03b769ee2e23..36dfff898f7a 100644 --- a/homeassistant/components/systemmonitor/coordinator.py +++ b/homeassistant/components/systemmonitor/coordinator.py @@ -12,11 +12,14 @@ from psutil import Process from psutil._common import sdiskusage, shwtemp, snetio, snicaddr, sswap import psutil_home_assistant as ha_psutil +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator from homeassistant.util import dt as dt_util +from .const import CONF_PROCESS, PROCESS_ERRORS + if TYPE_CHECKING: from . import SystemMonitorConfigEntry @@ -83,6 +86,8 @@ class VirtualMemory(NamedTuple): class SystemMonitorCoordinator(TimestampDataUpdateCoordinator[SensorData]): """A System monitor Data Update Coordinator.""" + config_entry: SystemMonitorConfigEntry + def __init__( self, hass: HomeAssistant, @@ -203,11 +208,30 @@ class SystemMonitorCoordinator(TimestampDataUpdateCoordinator[SensorData]): self.boot_time = dt_util.utc_from_timestamp(self._psutil.boot_time()) _LOGGER.debug("boot time: %s", self.boot_time) - processes = None + selected_processes: list[Process] = [] if self.update_subscribers[("processes", "")] or self._initial_update: processes = self._psutil.process_iter() _LOGGER.debug("processes: %s", processes) - processes = list(processes) + user_options: list[str] = self.config_entry.options.get( + BINARY_SENSOR_DOMAIN, {} + ).get(CONF_PROCESS, []) + for process in processes: + try: + if process.name() in user_options: + selected_processes.append(process) + except PROCESS_ERRORS as err: + if not hasattr(err, "pid") or not hasattr(err, "name"): + _LOGGER.warning( + "Failed to load process: %s", + str(err), + ) + else: + _LOGGER.warning( + "Failed to load process with ID: %s, old name: %s", + err.pid, + err.name, + ) + continue temps: dict[str, list[shwtemp]] = {} if self.update_subscribers[("temperatures", "")] or self._initial_update: @@ -224,6 +248,6 @@ class SystemMonitorCoordinator(TimestampDataUpdateCoordinator[SensorData]): "io_counters": io_counters, "addresses": addresses, "boot_time": self.boot_time, - "processes": processes, + "processes": selected_processes, "temperatures": temps, } From ce363b383551add74dcf5134ee4204197798f6cd Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 23 Sep 2025 06:55:07 -0400 Subject: [PATCH 051/189] Make Roborock load_multi_map always cloud dependent. (#152698) --- homeassistant/components/roborock/coordinator.py | 4 ++-- homeassistant/components/roborock/select.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 507167f80cdc..39966273908d 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -426,7 +426,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): for map_flag in map_flags: if map_flag != cur_map: # Only change the map and sleep if we have multiple maps. - await self.api.load_multi_map(map_flag) + await self.cloud_api.load_multi_map(map_flag) self.current_map = map_flag # We cannot get the map until the roborock servers fully process the # map change. @@ -444,7 +444,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # Set the map back to the map the user previously had selected so that it # does not change the end user's app. # Only needs to happen when we changed maps above. - await self.api.load_multi_map(cur_map) + await self.cloud_api.load_multi_map(cur_map) self.current_map = cur_map diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 208020dccab8..4b03e03325b4 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -151,7 +151,7 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): if map_.name == option: await self._send_command( RoborockCommand.LOAD_MULTI_MAP, - self.api, + self.cloud_api, [map_id], ) # Update the current map id manually so that nothing gets broken From 21d4ed28372c5d0259986d551c5177acda7f6eb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C4=8Cerm=C3=A1k?= Date: Tue, 23 Sep 2025 12:55:29 +0200 Subject: [PATCH 052/189] Add profiler service for dumping sockets used by HA (#152440) Co-authored-by: Stefan Agner --- homeassistant/components/profiler/__init__.py | 17 ++++++++++ homeassistant/components/profiler/icons.json | 3 ++ .../components/profiler/services.yaml | 1 + .../components/profiler/strings.json | 4 +++ tests/components/profiler/test_init.py | 32 +++++++++++++++++++ 5 files changed, 57 insertions(+) diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index 749b73e5aee3..66b35eaff210 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -35,6 +35,7 @@ SERVICE_STOP_LOG_OBJECTS = "stop_log_objects" SERVICE_START_LOG_OBJECT_SOURCES = "start_log_object_sources" SERVICE_STOP_LOG_OBJECT_SOURCES = "stop_log_object_sources" SERVICE_DUMP_LOG_OBJECTS = "dump_log_objects" +SERVICE_DUMP_SOCKETS = "dump_sockets" SERVICE_LRU_STATS = "lru_stats" SERVICE_LOG_THREAD_FRAMES = "log_thread_frames" SERVICE_LOG_EVENT_LOOP_SCHEDULED = "log_event_loop_scheduled" @@ -231,6 +232,15 @@ async def async_setup_entry( # noqa: C901 notification_id="profile_lru_stats", ) + def _dump_sockets(call: ServiceCall) -> None: + """Dump list of all currently existing sockets to the log.""" + import objgraph # noqa: PLC0415 + + _LOGGER.critical( + "Sockets used by Home Assistant:\n%s", + "\n".join(repr(sock) for sock in objgraph.by_type("socket")), + ) + async def _async_dump_thread_frames(call: ServiceCall) -> None: """Log all thread frames.""" frames = sys._current_frames() # noqa: SLF001 @@ -346,6 +356,13 @@ async def async_setup_entry( # noqa: C901 schema=vol.Schema({vol.Required(CONF_TYPE): str}), ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_DUMP_SOCKETS, + _dump_sockets, + ) + async_register_admin_service( hass, DOMAIN, diff --git a/homeassistant/components/profiler/icons.json b/homeassistant/components/profiler/icons.json index c1f996b6eb15..0c6a4f2600a0 100644 --- a/homeassistant/components/profiler/icons.json +++ b/homeassistant/components/profiler/icons.json @@ -15,6 +15,9 @@ "dump_log_objects": { "service": "mdi:invoice-export-outline" }, + "dump_sockets": { + "service": "mdi:pipe" + }, "start_log_object_sources": { "service": "mdi:play" }, diff --git a/homeassistant/components/profiler/services.yaml b/homeassistant/components/profiler/services.yaml index 82cdcf8d96ef..d0b8cc098325 100644 --- a/homeassistant/components/profiler/services.yaml +++ b/homeassistant/components/profiler/services.yaml @@ -51,6 +51,7 @@ start_log_object_sources: unit_of_measurement: objects stop_log_object_sources: lru_stats: +dump_sockets: log_thread_frames: log_event_loop_scheduled: set_asyncio_debug: diff --git a/homeassistant/components/profiler/strings.json b/homeassistant/components/profiler/strings.json index f363b5a22cbf..ccbf42bb46ba 100644 --- a/homeassistant/components/profiler/strings.json +++ b/homeassistant/components/profiler/strings.json @@ -65,6 +65,10 @@ } } }, + "dump_sockets": { + "name": "Dump used sockets", + "description": "Logs information about all currently used sockets." + }, "stop_log_object_sources": { "name": "Stop logging object sources", "description": "Stops logging sources of new objects in memory." diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index e724a9e5cab5..941d639a419a 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -5,6 +5,7 @@ from functools import lru_cache import logging import os from pathlib import Path +import socket from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory @@ -18,6 +19,7 @@ from homeassistant.components.profiler import ( CONF_ENABLED, CONF_SECONDS, SERVICE_DUMP_LOG_OBJECTS, + SERVICE_DUMP_SOCKETS, SERVICE_LOG_CURRENT_TASKS, SERVICE_LOG_EVENT_LOOP_SCHEDULED, SERVICE_LOG_THREAD_FRAMES, @@ -271,6 +273,36 @@ async def test_log_scheduled( await hass.async_block_till_done() +@pytest.mark.usefixtures("socket_enabled") +async def test_dump_sockets( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test dumping of sockets to the log.""" + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + caplog.clear() + + sock = None + try: + # Try to bind ephemeral UDP port on localhost for testing + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + + assert hass.services.has_service(DOMAIN, SERVICE_DUMP_SOCKETS) + await hass.services.async_call(DOMAIN, SERVICE_DUMP_SOCKETS, blocking=True) + finally: + if sock: + sock.close() + + assert "Sockets used by Home Assistant" in caplog.text + assert f"laddr=('127.0.0.1', {port})" in caplog.text + + async def test_lru_stats(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: """Test logging lru stats.""" From 0f8e70096532c56816f8c7134142ae19a4368ded Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 23 Sep 2025 12:55:44 +0200 Subject: [PATCH 053/189] Add a cable unplugged sensor for Shelly Flood Gen4 (#152559) --- .../components/shelly/binary_sensor.py | 11 +++++ .../shelly/snapshots/test_binary_sensor.ambr | 49 +++++++++++++++++++ tests/components/shelly/test_binary_sensor.py | 24 ++++++++- 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 7e77b0d07895..d292e2baf38b 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -298,6 +298,17 @@ RPC_SENSORS: Final = { name="Mute", entity_category=EntityCategory.DIAGNOSTIC, ), + "flood_cable_unplugged": RpcBinarySensorDescription( + key="flood", + sub_key="errors", + value=lambda status, _: False + if status is None + else "cable_unplugged" in status, + name="Cable unplugged", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + supported=lambda status: status.get("alarm") is not None, + ), "presence_num_objects": RpcBinarySensorDescription( key="presence", sub_key="num_objects", diff --git a/tests/components/shelly/snapshots/test_binary_sensor.ambr b/tests/components/shelly/snapshots/test_binary_sensor.ambr index 201f20c3de94..5388dcfedc61 100644 --- a/tests/components/shelly/snapshots/test_binary_sensor.ambr +++ b/tests/components/shelly/snapshots/test_binary_sensor.ambr @@ -48,6 +48,55 @@ 'state': 'off', }) # --- +# name: test_rpc_flood_entities[binary_sensor.test_name_kitchen_cable_unplugged-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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_name_kitchen_cable_unplugged', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Kitchen cable unplugged', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-flood:0-flood_cable_unplugged', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_flood_entities[binary_sensor.test_name_kitchen_cable_unplugged-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Kitchen cable unplugged', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_kitchen_cable_unplugged', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_rpc_flood_entities[binary_sensor.test_name_kitchen_flood-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index 113903ba140a..db0b05aec955 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -542,7 +542,7 @@ async def test_rpc_flood_entities( """Test RPC flood sensor entities.""" await init_integration(hass, 4) - for entity in ("flood", "mute"): + for entity in ("flood", "mute", "cable_unplugged"): entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_kitchen_{entity}" state = hass.states.get(entity_id) @@ -552,6 +552,28 @@ async def test_rpc_flood_entities( assert entry == snapshot(name=f"{entity_id}-entry") +async def test_rpc_flood_cable_unplugged( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test RPC flood cable unplugged entity.""" + await init_integration(hass, 4) + + entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_kitchen_cable_unplugged" + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF + + status = deepcopy(mock_rpc_device.status) + status["flood:0"]["errors"] = ["cable_unplugged"] + monkeypatch.setattr(mock_rpc_device, "status", status) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON + + async def test_rpc_presence_component( hass: HomeAssistant, mock_rpc_device: Mock, From 90bfadda9b2cc70e2a21dcf7ecb120b29924df8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C4=8Cerm=C3=A1k?= Date: Tue, 23 Sep 2025 12:56:27 +0200 Subject: [PATCH 054/189] Support all reported preset modes in Smartthings climate (#148056) Co-authored-by: abmantis --- .../components/smartthings/climate.py | 14 +- tests/components/smartthings/conftest.py | 1 + .../device_status/da_ac_rac_000002.json | 886 ++++++++++++++++++ .../fixtures/devices/da_ac_rac_000002.json | 303 ++++++ .../smartthings/snapshots/test_climate.ambr | 153 ++- .../smartthings/snapshots/test_init.ambr | 31 + .../smartthings/snapshots/test_sensor.ambr | 440 +++++++++ tests/components/smartthings/test_climate.py | 68 +- 8 files changed, 1880 insertions(+), 16 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/da_ac_rac_000002.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ac_rac_000002.json diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index f87c9bbfcef0..98581af9fe89 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -101,7 +101,6 @@ HA_MODE_TO_HEAT_PUMP_AC_MODE = {v: k for k, v in HEAT_PUMP_AC_MODE_TO_HA.items() WIND = "wind" FAN = "fan" -WINDFREE = "windFree" _LOGGER = logging.getLogger(__name__) @@ -577,14 +576,15 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): @property def preset_mode(self) -> str | None: - """Return the preset mode.""" + """Return the current preset mode.""" if self.supports_capability(Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE): mode = self.get_attribute_value( Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, Attribute.AC_OPTIONAL_MODE, ) - if mode == WINDFREE: - return WINDFREE + # Return the mode if it is in the supported modes + if self._attr_preset_modes and mode in self._attr_preset_modes: + return mode return None def _determine_preset_modes(self) -> list[str] | None: @@ -594,12 +594,12 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, Attribute.SUPPORTED_AC_OPTIONAL_MODE, ) - if supported_modes and WINDFREE in supported_modes: - return [WINDFREE] + if supported_modes: + return supported_modes return None async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set special modes (currently only windFree is supported).""" + """Set optional AC modes.""" await self.execute_device_command( Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, Command.SET_AC_OPTIONAL_MODE, diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index c45417122e92..b28a7c761f4c 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -99,6 +99,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "aq_sensor_3_ikea", "da_ac_airsensor_01001", "da_ac_rac_000001", + "da_ac_rac_000002", "da_ac_rac_000003", "da_ac_rac_100001", "da_ac_rac_01001", diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000002.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000002.json new file mode 100644 index 000000000000..1dce4ae52614 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000002.json @@ -0,0 +1,886 @@ +{ + "components": { + "1": { + "relativeHumidityMeasurement": { + "humidity": { + "value": 0, + "unit": "%", + "timestamp": "2021-04-06T16:43:35.291Z" + } + }, + "custom.airConditionerOdorController": { + "airConditionerOdorControllerProgress": { + "value": null, + "timestamp": "2021-04-08T04:11:38.269Z" + }, + "airConditionerOdorControllerState": { + "value": null, + "timestamp": "2021-04-08T04:11:38.269Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": null, + "timestamp": "2021-04-08T04:04:19.901Z" + }, + "maximumSetpoint": { + "value": null, + "timestamp": "2021-04-08T04:04:19.901Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": null, + "timestamp": "2021-04-08T03:50:50.930Z" + }, + "airConditionerMode": { + "value": null, + "timestamp": "2021-04-08T03:50:50.930Z" + } + }, + "custom.spiMode": { + "spiMode": { + "value": null, + "timestamp": "2021-04-06T16:57:57.686Z" + } + }, + "airQualitySensor": { + "airQuality": { + "value": null, + "unit": "CAQI", + "timestamp": "2021-04-06T16:57:57.602Z" + } + }, + "custom.airConditionerOptionalMode": { + "supportedAcOptionalMode": { + "value": null, + "timestamp": "2021-04-06T16:57:57.659Z" + }, + "acOptionalMode": { + "value": null, + "timestamp": "2021-04-06T16:57:57.659Z" + } + }, + "switch": { + "switch": { + "value": null, + "timestamp": "2021-04-06T16:44:10.518Z" + } + }, + "custom.airConditionerTropicalNightMode": { + "acTropicalNightModeLevel": { + "value": null, + "timestamp": "2021-04-06T16:44:10.498Z" + } + }, + "ocf": { + "st": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mndt": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnfv": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnhw": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "di": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnsl": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "dmv": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "n": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnmo": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "vid": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnmn": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnml": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnpv": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnos": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "pi": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "icv": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": null, + "timestamp": "2021-04-06T16:44:10.381Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2024-09-10T10:26:28.605Z" + }, + "availableAcFanModes": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "remoteControlStatus", + "airQualitySensor", + "dustSensor", + "odorSensor", + "veryFineDustSensor", + "custom.dustFilter", + "custom.deodorFilter", + "custom.deviceReportStateConfiguration", + "audioVolume", + "custom.autoCleaningMode", + "custom.airConditionerTropicalNightMode", + "custom.airConditionerOdorController", + "demandResponseLoadControl", + "relativeHumidityMeasurement" + ], + "timestamp": "2024-09-10T10:26:28.605Z" + } + }, + "fanOscillationMode": { + "supportedFanOscillationModes": { + "value": null, + "timestamp": "2021-04-06T16:44:10.325Z" + }, + "availableFanOscillationModes": { + "value": null + }, + "fanOscillationMode": { + "value": "fixed", + "timestamp": "2025-02-08T00:44:53.247Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null, + "timestamp": "2021-04-06T16:44:10.373Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:44:10.122Z" + }, + "fineDustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:44:10.122Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": null, + "timestamp": "2021-04-06T16:44:09.800Z" + }, + "reportStateRealtime": { + "value": null, + "timestamp": "2021-04-06T16:44:09.800Z" + }, + "reportStatePeriod": { + "value": null, + "timestamp": "2021-04-06T16:44:09.800Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null, + "timestamp": "2021-04-06T16:43:59.136Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:54.748Z" + } + }, + "audioVolume": { + "volume": { + "value": null, + "unit": "%", + "timestamp": "2021-04-06T16:43:53.541Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": null, + "timestamp": "2021-04-06T16:43:53.364Z" + } + }, + "custom.autoCleaningMode": { + "supportedAutoCleaningModes": { + "value": null + }, + "timedCleanDuration": { + "value": null + }, + "operatingState": { + "value": null + }, + "timedCleanDurationRange": { + "value": null + }, + "supportedOperatingStates": { + "value": null + }, + "progress": { + "value": null + }, + "autoCleaningMode": { + "value": null, + "timestamp": "2021-04-06T16:43:53.344Z" + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterUsage": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterLastResetDate": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterCapacity": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterResetType": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + } + }, + "odorSensor": { + "odorLevel": { + "value": null, + "timestamp": "2021-04-06T16:43:38.992Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": null, + "timestamp": "2021-04-06T16:43:39.097Z" + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterLastResetDate": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterResetType": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterUsage": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterUsageStep": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + } + }, + "custom.energyType": { + "energyType": { + "value": null, + "timestamp": "2021-04-06T16:43:38.843Z" + }, + "energySavingSupport": { + "value": null + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "veryFineDustSensor": { + "veryFineDustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:43:38.529Z" + } + } + }, + "main": { + "relativeHumidityMeasurement": { + "humidity": { + "value": 60, + "unit": "%", + "timestamp": "2024-12-30T13:10:23.759Z" + } + }, + "custom.airConditionerOdorController": { + "airConditionerOdorControllerProgress": { + "value": null, + "timestamp": "2021-04-06T16:43:37.555Z" + }, + "airConditionerOdorControllerState": { + "value": null, + "timestamp": "2021-04-06T16:43:37.555Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 16, + "unit": "C", + "timestamp": "2025-01-08T06:30:58.307Z" + }, + "maximumSetpoint": { + "value": 30, + "unit": "C", + "timestamp": "2024-09-10T10:26:28.781Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["cool", "dry", "wind", "auto", "heat"], + "timestamp": "2024-09-10T10:26:28.781Z" + }, + "airConditionerMode": { + "value": "heat", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "custom.spiMode": { + "spiMode": { + "value": "off", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2021-12-29T01:36:51.289Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "ARTIK051_KRAC_18K", + "timestamp": "2025-02-08T00:44:53.855Z" + } + }, + "airQualitySensor": { + "airQuality": { + "value": null, + "unit": "CAQI", + "timestamp": "2021-04-06T16:43:37.208Z" + } + }, + "custom.airConditionerOptionalMode": { + "supportedAcOptionalMode": { + "value": [ + "off", + "sleep", + "quiet", + "speed", + "windFree", + "windFreeSleep" + ], + "timestamp": "2024-09-10T10:26:28.781Z" + }, + "acOptionalMode": { + "value": "windFree", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T16:37:54.072Z" + } + }, + "custom.airConditionerTropicalNightMode": { + "acTropicalNightModeLevel": { + "value": 0, + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "ocf": { + "st": { + "value": null, + "timestamp": "2021-04-06T16:43:35.933Z" + }, + "mndt": { + "value": null, + "timestamp": "2021-04-06T16:43:35.912Z" + }, + "mnfv": { + "value": "0.1.0", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnhw": { + "value": "1.0", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "di": { + "value": "13549124-3320-4fda-8e5c-3f363e043034", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnsl": { + "value": null, + "timestamp": "2021-04-06T16:43:35.803Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "n": { + "value": "[room a/c] Samsung", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnmo": { + "value": "ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000", + "timestamp": "2024-09-10T10:26:28.781Z" + }, + "vid": { + "value": "DA-AC-RAC-000001", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnpv": { + "value": "0G3MPDCKA00010E", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnos": { + "value": "TizenRT2.0", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "pi": { + "value": "13549124-3320-4fda-8e5c-3f363e043034", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-09-10T10:26:28.552Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": "low", + "timestamp": "2025-02-09T09:14:39.249Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2025-02-09T09:14:39.249Z" + }, + "availableAcFanModes": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "remoteControlStatus", + "airQualitySensor", + "dustSensor", + "veryFineDustSensor", + "custom.dustFilter", + "custom.deodorFilter", + "custom.deviceReportStateConfiguration", + "samsungce.dongleSoftwareInstallation", + "demandResponseLoadControl", + "custom.airConditionerOdorController" + ], + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24070101, + "timestamp": "2024-09-04T06:35:09.557Z" + } + }, + "fanOscillationMode": { + "supportedFanOscillationModes": { + "value": null, + "timestamp": "2021-04-06T16:43:35.782Z" + }, + "availableFanOscillationModes": { + "value": null + }, + "fanOscillationMode": { + "value": "fixed", + "timestamp": "2025-02-09T09:14:39.249Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 25, + "unit": "C", + "timestamp": "2025-02-09T16:33:29.164Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:43:35.665Z" + }, + "fineDustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:43:35.665Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": null, + "timestamp": "2021-04-06T16:43:35.643Z" + }, + "reportStateRealtime": { + "value": null, + "timestamp": "2021-04-06T16:43:35.643Z" + }, + "reportStatePeriod": { + "value": null, + "timestamp": "2021-04-06T16:43:35.643Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 25, + "unit": "C", + "timestamp": "2025-02-09T09:15:11.608Z" + } + }, + "custom.disabledComponents": { + "disabledComponents": { + "value": ["1"], + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": -1, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + }, + "timestamp": "2024-09-10T10:26:28.781Z" + } + }, + "audioVolume": { + "volume": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 2247300, + "deltaEnergy": 400, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 2247300, + "energySaved": 0, + "start": "2025-02-09T15:45:29Z", + "end": "2025-02-09T16:15:33Z" + }, + "timestamp": "2025-02-09T16:15:33.639Z" + } + }, + "custom.autoCleaningMode": { + "supportedAutoCleaningModes": { + "value": null + }, + "timedCleanDuration": { + "value": null + }, + "operatingState": { + "value": null + }, + "timedCleanDurationRange": { + "value": null + }, + "supportedOperatingStates": { + "value": null + }, + "progress": { + "value": null + }, + "autoCleaningMode": { + "value": "off", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "refresh": {}, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["oic.r.temperature"], + "if": ["oic.if.baseline", "oic.if.a"], + "range": [16.0, 30.0], + "units": "C", + "temperature": 22.0 + } + }, + "data": { + "href": "/temperature/desired/0" + }, + "timestamp": "2023-07-19T03:07:43.270Z" + } + }, + "samsungce.selfCheck": { + "result": { + "value": null + }, + "supportedActions": { + "value": ["start"], + "timestamp": "2024-09-04T06:35:09.557Z" + }, + "progress": { + "value": null + }, + "errors": { + "value": [], + "timestamp": "2025-02-08T00:44:53.349Z" + }, + "status": { + "value": "ready", + "timestamp": "2025-02-08T00:44:53.549Z" + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterUsage": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterLastResetDate": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterCapacity": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterResetType": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": null, + "timestamp": "2021-04-06T16:43:35.379Z" + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterLastResetDate": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterResetType": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterUsage": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterUsageStep": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "1.0", + "timestamp": "2024-09-10T10:26:28.781Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2021-12-29T07:29:17.526Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "43CEZFTFFL7Z2", + "timestamp": "2025-02-08T00:44:53.855Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-02-08T00:44:53.855Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-08T00:44:53.855Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "veryFineDustSensor": { + "veryFineDustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:43:35.363Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_000002.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_000002.json new file mode 100644 index 000000000000..f14341897601 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_000002.json @@ -0,0 +1,303 @@ +{ + "items": [ + { + "deviceId": "13549124-3320-4fda-8e5c-3f363e043034", + "name": "[room a/c] Samsung", + "label": "AC Office Granit", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-AC-RAC-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "58d3fd7c-c512-4da3-b500-ef269382756c", + "ownerId": "f9a28d7c-1ed5-d9e9-a81c-18971ec081db", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "deviceTypeName": "Samsung OCF Air Conditioner", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "fanOscillationMode", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "veryFineDustSensor", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "custom.spiMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.airConditionerOptionalMode", + "version": 1 + }, + { + "id": "custom.airConditionerTropicalNightMode", + "version": 1 + }, + { + "id": "custom.autoCleaningMode", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.airConditionerOdorController", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dongleSoftwareInstallation", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.selfCheck", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "1", + "label": "1", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "fanOscillationMode", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "veryFineDustSensor", + "version": 1 + }, + { + "id": "odorSensor", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.autoCleaningMode", + "version": 1 + }, + { + "id": "custom.airConditionerTropicalNightMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "custom.spiMode", + "version": 1 + }, + { + "id": "custom.airConditionerOptionalMode", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.airConditionerOdorController", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2021-04-06T16:43:34.753Z", + "profile": { + "id": "60fbc713-8da5-315d-b31a-6d6dcde4be7b" + }, + "ocf": { + "ocfDeviceType": "x.com.st.d.sensor.light", + "manufacturerName": "Samsung Electronics", + "vendorId": "VD-Sensor.Light-2023", + "lastSignupTime": "2025-01-08T02:32:04.631093137Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 75c0ad63611a..6976371376c3 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -153,7 +153,12 @@ 'max_temp': 35, 'min_temp': 7, 'preset_modes': list([ + 'off', 'windFree', + 'longWind', + 'speed', + 'quiet', + 'sleep', ]), 'swing_modes': list([ 'vertical', @@ -217,9 +222,14 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'preset_mode': None, + 'preset_mode': 'off', 'preset_modes': list([ + 'off', 'windFree', + 'longWind', + 'speed', + 'quiet', + 'sleep', ]), 'supported_features': , 'swing_mode': 'off', @@ -331,6 +341,7 @@ 'max_temp': 35, 'min_temp': 7, 'preset_modes': list([ + 'off', 'windFree', ]), 'swing_modes': None, @@ -393,6 +404,7 @@ 'min_temp': 7, 'preset_mode': 'windFree', 'preset_modes': list([ + 'off', 'windFree', ]), 'supported_features': , @@ -408,6 +420,117 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ac_rac_000002][climate.ac_office_granit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_modes': list([ + 'off', + 'sleep', + 'quiet', + 'speed', + 'windFree', + 'windFreeSleep', + ]), + 'swing_modes': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.ac_office_granit', + 'has_entity_name': 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': '13549124-3320-4fda-8e5c-3f363e043034_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_000002][climate.ac_office_granit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 25, + 'drlc_status_duration': 0, + 'drlc_status_level': -1, + 'drlc_status_override': False, + 'drlc_status_start': '1970-01-01T00:00:00Z', + 'fan_mode': 'low', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'friendly_name': 'AC Office Granit', + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': 'windFree', + 'preset_modes': list([ + 'off', + 'sleep', + 'quiet', + 'speed', + 'windFree', + 'windFreeSleep', + ]), + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': None, + 'temperature': 25, + }), + 'context': , + 'entity_id': 'climate.ac_office_granit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ac_rac_000003][climate.office_airfree-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -431,7 +554,13 @@ 'max_temp': 35, 'min_temp': 7, 'preset_modes': list([ + 'off', + 'sleep', + 'quiet', + 'smart', + 'speed', 'windFree', + 'windFreeSleep', ]), 'swing_modes': list([ 'off', @@ -493,9 +622,15 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'preset_mode': None, + 'preset_mode': 'off', 'preset_modes': list([ + 'off', + 'sleep', + 'quiet', + 'smart', + 'speed', 'windFree', + 'windFreeSleep', ]), 'supported_features': , 'swing_mode': 'off', @@ -539,7 +674,13 @@ 'max_temp': 35, 'min_temp': 7, 'preset_modes': list([ + 'off', + 'sleep', + 'quiet', + 'smart', + 'speed', 'windFree', + 'windFreeSleep', ]), 'swing_modes': list([ 'off', @@ -604,9 +745,15 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'preset_mode': None, + 'preset_mode': 'off', 'preset_modes': list([ + 'off', + 'sleep', + 'quiet', + 'smart', + 'speed', 'windFree', + 'windFreeSleep', ]), 'supported_features': , 'swing_mode': 'off', diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 5cd56c316839..0de7bcc5bf0c 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -436,6 +436,37 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ac_rac_000002] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + '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', + '13549124-3320-4fda-8e5c-3f363e043034', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': None, + 'model_id': None, + 'name': 'AC Office Granit', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[da_ac_rac_000003] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 9e83fdacab91..78c5ba9bed15 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2509,6 +2509,446 @@ 'state': '100', }) # --- +# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_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.ac_office_granit_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': '13549124-3320-4fda-8e5c-3f363e043034_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AC Office Granit Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2247.3', + }) +# --- +# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_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.ac_office_granit_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': '13549124-3320-4fda-8e5c-3f363e043034_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AC Office Granit Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.4', + }) +# --- +# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_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.ac_office_granit_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': '13549124-3320-4fda-8e5c-3f363e043034_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AC Office Granit Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_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.ac_office_granit_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': '13549124-3320-4fda-8e5c-3f363e043034_main_relativeHumidityMeasurement_humidity_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'AC Office Granit Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_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.ac_office_granit_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': '13549124-3320-4fda-8e5c-3f363e043034_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'AC Office Granit Power', + 'power_consumption_end': '2025-02-09T16:15:33Z', + 'power_consumption_start': '2025-02-09T15:45:29Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_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.ac_office_granit_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': '13549124-3320-4fda-8e5c-3f363e043034_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AC Office Granit Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_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.ac_office_granit_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': '13549124-3320-4fda-8e5c-3f363e043034_main_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'AC Office Granit Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25', + }) +# --- +# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'audio_volume', + 'unique_id': '13549124-3320-4fda-8e5c-3f363e043034_main_audioVolume_volume_volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AC Office Granit Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- # name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index 6f2325cad788..e1a8129c873b 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -441,30 +441,86 @@ async def test_ac_set_swing_mode( ) -@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000002"]) +@pytest.mark.parametrize( + "mode", ["off", "sleep", "quiet", "speed", "windFree", "windFreeSleep"] +) async def test_ac_set_preset_mode( hass: HomeAssistant, devices: AsyncMock, + mode: str, mock_config_entry: MockConfigEntry, ) -> None: - """Test climate set preset mode.""" + """Test setting and retrieving AC preset modes.""" await setup_integration(hass, mock_config_entry) + # Mock supported preset modes + set_attribute_value( + devices, + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Attribute.SUPPORTED_AC_OPTIONAL_MODE, + ["off", "sleep", "quiet", "speed", "windFree", "windFreeSleep"], + ) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_PRESET_MODE: "windFree"}, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_PRESET_MODE: mode}, blocking=True, ) - devices.execute_device_command.assert_called_once_with( - "96a5ef74-5832-a84b-f1f7-ca799957065d", + devices.execute_device_command.assert_called_with( + "13549124-3320-4fda-8e5c-3f363e043034", Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, Command.SET_AC_OPTIONAL_MODE, MAIN, - argument="windFree", + argument=mode, ) +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000002"]) +@pytest.mark.parametrize( + "mode", ["off", "sleep", "quiet", "speed", "windFree", "windFreeSleep"] +) +async def test_ac_get_preset_mode( + hass: HomeAssistant, + devices: AsyncMock, + mode: str, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting and retrieving AC preset modes.""" + await setup_integration(hass, mock_config_entry) + + # Mock supported preset modes + set_attribute_value( + devices, + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Attribute.SUPPORTED_AC_OPTIONAL_MODE, + ["off", "sleep", "quiet", "speed", "windFree", "windFreeSleep"], + ) + + # Mock the current preset mode to simulate the device state + set_attribute_value( + devices, + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Attribute.AC_OPTIONAL_MODE, + mode, + ) + + # Trigger an update to refresh the state + await trigger_update( + hass, + devices, + "13549124-3320-4fda-8e5c-3f363e043034", + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Attribute.AC_OPTIONAL_MODE, + mode, + ) + + # Verify the preset mode is correctly reflected in the entity state + state = hass.states.get("climate.ac_office_granit") + assert state.attributes[ATTR_PRESET_MODE] == mode + + @pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) async def test_ac_state_update( hass: HomeAssistant, From a0f67381e578a5aa3735cdef6cc1fd46a70b81a3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 23 Sep 2025 06:58:36 -0400 Subject: [PATCH 055/189] Allow configuring Z-Wave JS to talk via ESPHome (#152590) Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/esphome/entry_data.py | 29 +- homeassistant/components/esphome/manager.py | 2 +- homeassistant/components/zwave_js/__init__.py | 32 +- .../components/zwave_js/config_flow.py | 126 ++++-- homeassistant/components/zwave_js/const.py | 3 + .../components/zwave_js/strings.json | 3 +- homeassistant/config_entries.py | 16 +- homeassistant/helpers/service_info/esphome.py | 26 ++ tests/components/esphome/test_entry_data.py | 53 ++- tests/components/zwave_js/test_config_flow.py | 373 +++++++++++++++--- tests/helpers/test_service_info.py | 14 + 11 files changed, 590 insertions(+), 87 deletions(-) create mode 100644 homeassistant/helpers/service_info/esphome.py diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 820492661756..f329d8ba11a8 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -49,11 +49,13 @@ from aioesphomeapi import ( from aioesphomeapi.model import ButtonInfo from bleak_esphome.backend.device import ESPHomeBluetoothDevice +from homeassistant import config_entries from homeassistant.components.assist_satellite import AssistSatelliteConfiguration from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import discovery_flow, entity_registry as er +from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo from homeassistant.helpers.storage import Store from .const import DOMAIN @@ -468,7 +470,7 @@ class RuntimeEntryData: @callback def async_on_connect( - self, device_info: DeviceInfo, api_version: APIVersion + self, hass: HomeAssistant, device_info: DeviceInfo, api_version: APIVersion ) -> None: """Call when the entry has been connected.""" self.available = True @@ -484,6 +486,29 @@ class RuntimeEntryData: # be marked as unavailable or not. self.expected_disconnect = True + if not device_info.zwave_proxy_feature_flags: + return + + assert self.client.connected_address + + discovery_flow.async_create_flow( + hass, + "zwave_js", + {"source": config_entries.SOURCE_ESPHOME}, + ESPHomeServiceInfo( + name=device_info.name, + zwave_home_id=device_info.zwave_home_id or None, + ip_address=self.client.connected_address, + port=self.client.port, + noise_psk=self.client.noise_psk, + ), + discovery_key=discovery_flow.DiscoveryKey( + domain=DOMAIN, + key=device_info.mac_address, + version=1, + ), + ) + @callback def async_register_assist_satellite_config_updated_callback( self, diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 74b429cdfa14..a14eb3f5a164 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -505,7 +505,7 @@ class ESPHomeManager: api_version = cli.api_version assert api_version is not None, "API version must be set" - entry_data.async_on_connect(device_info, api_version) + entry_data.async_on_connect(hass, device_info, api_version) await self._handle_dynamic_encryption_key(device_info) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index f78c201340aa..2076c37856e9 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -91,6 +91,7 @@ from .const import ( CONF_ADDON_S2_ACCESS_CONTROL_KEY, CONF_ADDON_S2_AUTHENTICATED_KEY, CONF_ADDON_S2_UNAUTHENTICATED_KEY, + CONF_ADDON_SOCKET, CONF_DATA_COLLECTION_OPTED_IN, CONF_INSTALLER_MODE, CONF_INTEGRATION_CREATED_ADDON, @@ -102,9 +103,11 @@ from .const import ( CONF_S2_ACCESS_CONTROL_KEY, CONF_S2_AUTHENTICATED_KEY, CONF_S2_UNAUTHENTICATED_KEY, + CONF_SOCKET_PATH, CONF_USB_PATH, CONF_USE_ADDON, DOMAIN, + ESPHOME_ADDON_VERSION, EVENT_DEVICE_ADDED_TO_REGISTRY, EVENT_VALUE_UPDATED, LIB_LOGGER, @@ -1174,7 +1177,16 @@ async def async_ensure_addon_running( except AddonError as err: raise ConfigEntryNotReady(err) from err - usb_path: str = entry.data[CONF_USB_PATH] + addon_has_lr = ( + addon_info.version and AwesomeVersion(addon_info.version) >= LR_ADDON_VERSION + ) + addon_has_esphome = ( + addon_info.version + and AwesomeVersion(addon_info.version) >= ESPHOME_ADDON_VERSION + ) + + usb_path: str | None = entry.data[CONF_USB_PATH] + socket_path: str | None = entry.data.get(CONF_SOCKET_PATH) # s0_legacy_key was saved as network_key before s2 was added. s0_legacy_key: str = entry.data.get(CONF_S0_LEGACY_KEY, "") if not s0_legacy_key: @@ -1186,15 +1198,18 @@ async def async_ensure_addon_running( lr_s2_authenticated_key: str = entry.data.get(CONF_LR_S2_AUTHENTICATED_KEY, "") addon_state = addon_info.state addon_config = { - CONF_ADDON_DEVICE: usb_path, CONF_ADDON_S0_LEGACY_KEY: s0_legacy_key, CONF_ADDON_S2_ACCESS_CONTROL_KEY: s2_access_control_key, CONF_ADDON_S2_AUTHENTICATED_KEY: s2_authenticated_key, CONF_ADDON_S2_UNAUTHENTICATED_KEY: s2_unauthenticated_key, } - if addon_info.version and AwesomeVersion(addon_info.version) >= LR_ADDON_VERSION: + if usb_path is not None: + addon_config[CONF_ADDON_DEVICE] = usb_path + if addon_has_lr: addon_config[CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY] = lr_s2_access_control_key addon_config[CONF_ADDON_LR_S2_AUTHENTICATED_KEY] = lr_s2_authenticated_key + if addon_has_esphome and socket_path is not None: + addon_config[CONF_ADDON_SOCKET] = socket_path if addon_state == AddonState.NOT_INSTALLED: addon_manager.async_schedule_install_setup_addon( @@ -1211,7 +1226,7 @@ async def async_ensure_addon_running( raise ConfigEntryNotReady addon_options = addon_info.options - addon_device = addon_options[CONF_ADDON_DEVICE] + addon_device = addon_options.get(CONF_ADDON_DEVICE) # s0_legacy_key was saved as network_key before s2 was added. addon_s0_legacy_key = addon_options.get(CONF_ADDON_S0_LEGACY_KEY, "") if not addon_s0_legacy_key: @@ -1235,9 +1250,7 @@ async def async_ensure_addon_running( if s2_unauthenticated_key != addon_s2_unauthenticated_key: updates[CONF_S2_UNAUTHENTICATED_KEY] = addon_s2_unauthenticated_key - if addon_info.version and AwesomeVersion(addon_info.version) >= AwesomeVersion( - LR_ADDON_VERSION - ): + if addon_has_lr: addon_lr_s2_access_control_key = addon_options.get( CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY, "" ) @@ -1249,6 +1262,11 @@ async def async_ensure_addon_running( if lr_s2_authenticated_key != addon_lr_s2_authenticated_key: updates[CONF_LR_S2_AUTHENTICATED_KEY] = addon_lr_s2_authenticated_key + if addon_has_esphome: + addon_socket = addon_options.get(CONF_ADDON_SOCKET) + if socket_path != addon_socket: + updates[CONF_SOCKET_PATH] = addon_socket + if updates: hass.config_entries.async_update_entry(entry, data={**entry.data, **updates}) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 92912a2cdb58..be6efc03be9b 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -26,6 +26,7 @@ from homeassistant.components.hassio import ( AddonState, ) from homeassistant.config_entries import ( + SOURCE_ESPHOME, SOURCE_USB, ConfigEntryState, ConfigFlow, @@ -37,6 +38,7 @@ from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import selector from homeassistant.helpers.hassio import is_hassio +from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -52,6 +54,7 @@ from .const import ( CONF_ADDON_S2_ACCESS_CONTROL_KEY, CONF_ADDON_S2_AUTHENTICATED_KEY, CONF_ADDON_S2_UNAUTHENTICATED_KEY, + CONF_ADDON_SOCKET, CONF_INTEGRATION_CREATED_ADDON, CONF_KEEP_OLD_DEVICES, CONF_LR_S2_ACCESS_CONTROL_KEY, @@ -60,6 +63,7 @@ from .const import ( CONF_S2_ACCESS_CONTROL_KEY, CONF_S2_AUTHENTICATED_KEY, CONF_S2_UNAUTHENTICATED_KEY, + CONF_SOCKET_PATH, CONF_USB_PATH, CONF_USE_ADDON, DOMAIN, @@ -81,6 +85,7 @@ ADDON_SETUP_TIMEOUT_ROUNDS = 40 ADDON_USER_INPUT_MAP = { CONF_ADDON_DEVICE: CONF_USB_PATH, + CONF_ADDON_SOCKET: CONF_SOCKET_PATH, CONF_ADDON_S0_LEGACY_KEY: CONF_S0_LEGACY_KEY, CONF_ADDON_S2_ACCESS_CONTROL_KEY: CONF_S2_ACCESS_CONTROL_KEY, CONF_ADDON_S2_AUTHENTICATED_KEY: CONF_S2_AUTHENTICATED_KEY, @@ -129,7 +134,7 @@ def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema: def get_on_supervisor_schema(user_input: dict[str, Any]) -> vol.Schema: """Return a schema for the on Supervisor step.""" default_use_addon = user_input[CONF_USE_ADDON] - return vol.Schema({vol.Optional(CONF_USE_ADDON, default=default_use_addon): bool}) + return vol.Schema({vol.Required(CONF_USE_ADDON, default=default_use_addon): bool}) async def validate_input(hass: HomeAssistant, user_input: dict) -> VersionInfo: @@ -197,6 +202,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self.lr_s2_access_control_key: str | None = None self.lr_s2_authenticated_key: str | None = None self.usb_path: str | None = None + self.socket_path: str | None = None # ESPHome socket self.ws_address: str | None = None self.restart_addon: bool = False # If we install the add-on we should uninstall it on entry remove. @@ -214,7 +220,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self._addon_config_updates: dict[str, Any] = {} self._migrating = False self._reconfigure_config_entry: ZwaveJSConfigEntry | None = None - self._usb_discovery = False + self._adapter_discovered = False self._recommended_install = False self._rf_region: str | None = None @@ -370,6 +376,11 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): new_addon_config = addon_config | config_updates + if not new_addon_config[CONF_ADDON_DEVICE]: + new_addon_config.pop(CONF_ADDON_DEVICE) + if not new_addon_config[CONF_ADDON_SOCKET]: + new_addon_config.pop(CONF_ADDON_SOCKET) + if new_addon_config == addon_config: return @@ -542,7 +553,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): title = human_name.split(" - ")[0].strip() self.context["title_placeholders"] = {CONF_NAME: title} - self._usb_discovery = True + self._adapter_discovered = True if current_config_entries: return await self.async_step_confirm_usb_migration() @@ -658,7 +669,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Select custom installation type.""" - if self._usb_discovery: + if self._adapter_discovered: return await self.async_step_on_supervisor({CONF_USE_ADDON: True}) return await self.async_step_on_supervisor() @@ -706,7 +717,8 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): if addon_info.state == AddonState.RUNNING: addon_config = addon_info.options - self.usb_path = addon_config[CONF_ADDON_DEVICE] + self.usb_path = addon_config.get(CONF_ADDON_DEVICE) + self.socket_path = addon_config.get(CONF_ADDON_SOCKET) self.s0_legacy_key = addon_config.get(CONF_ADDON_S0_LEGACY_KEY, "") self.s2_access_control_key = addon_config.get( CONF_ADDON_S2_ACCESS_CONTROL_KEY, "" @@ -736,14 +748,13 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): """Ask for config for Z-Wave JS add-on.""" if user_input is not None: - self.usb_path = user_input[CONF_USB_PATH] + self.usb_path = user_input.get(CONF_USB_PATH) + self.socket_path = user_input.get(CONF_SOCKET_PATH) return await self.async_step_network_type() - if self._usb_discovery: + if self._adapter_discovered: return await self.async_step_network_type() - usb_path = self.usb_path or "" - try: ports = await async_get_usb_ports(self.hass) except OSError as err: @@ -752,7 +763,13 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): data_schema = vol.Schema( { - vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports), + vol.Optional( + CONF_USB_PATH, description={"suggested_value": self.usb_path} + ): vol.In(ports), + vol.Optional( + CONF_SOCKET_PATH, + description={"suggested_value": self.socket_path or ""}, + ): str, } ) @@ -780,6 +797,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): addon_config_updates = { CONF_ADDON_DEVICE: self.usb_path, + CONF_ADDON_SOCKET: self.socket_path, CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key, CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, @@ -851,6 +869,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): addon_config_updates = { CONF_ADDON_DEVICE: self.usb_path, + CONF_ADDON_SOCKET: self.socket_path, CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key, CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, @@ -899,7 +918,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): discovery_info = await self._async_get_addon_discovery_info() self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}" - if not self.unique_id or self.source == SOURCE_USB: + if not self.unique_id or self.source in (SOURCE_USB, SOURCE_ESPHOME): if not self.version_info: try: self.version_info = await async_get_version_info( @@ -916,6 +935,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): updates={ CONF_URL: self.ws_address, CONF_USB_PATH: self.usb_path, + CONF_SOCKET_PATH: self.socket_path, CONF_S0_LEGACY_KEY: self.s0_legacy_key, CONF_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, CONF_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, @@ -938,6 +958,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): data={ CONF_URL: self.ws_address, CONF_USB_PATH: self.usb_path, + CONF_SOCKET_PATH: self.socket_path, CONF_S0_LEGACY_KEY: self.s0_legacy_key, CONF_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, CONF_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, @@ -974,7 +995,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): """Confirm the user wants to reset their current controller.""" 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): + if not self._adapter_discovered and not config_entry.data.get(CONF_USE_ADDON): return self.async_abort( reason="addon_required", description_placeholders={ @@ -1062,9 +1083,10 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): """Instruct the user to unplug the old controller.""" if user_input is not None: - if self.usb_path: - # USB discovery was used, so the device is already known. + if self._adapter_discovered: + # Discovery was used, so the device is already known. self._addon_config_updates[CONF_ADDON_DEVICE] = self.usb_path + self._addon_config_updates[CONF_ADDON_SOCKET] = self.socket_path return await self.async_step_start_addon() # Now that the old controller is gone, we can scan for serial ports again return await self.async_step_choose_serial_port() @@ -1184,10 +1206,12 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self.s2_unauthenticated_key = user_input[CONF_S2_UNAUTHENTICATED_KEY] self.lr_s2_access_control_key = user_input[CONF_LR_S2_ACCESS_CONTROL_KEY] self.lr_s2_authenticated_key = user_input[CONF_LR_S2_AUTHENTICATED_KEY] - self.usb_path = user_input[CONF_USB_PATH] + self.usb_path = user_input.get(CONF_USB_PATH) + self.socket_path = user_input.get(CONF_SOCKET_PATH) addon_config_updates = { CONF_ADDON_DEVICE: self.usb_path, + CONF_ADDON_SOCKET: self.socket_path, CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key, CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, @@ -1198,6 +1222,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): addon_config_updates = self._addon_config_updates | addon_config_updates self._addon_config_updates = {} + await self._async_set_addon_config(addon_config_updates) if addon_info.state == AddonState.RUNNING and not self.restart_addon: @@ -1212,6 +1237,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_start_addon() usb_path = addon_config.get(CONF_ADDON_DEVICE, self.usb_path or "") + socket_path = addon_config.get(CONF_ADDON_SOCKET, self.socket_path or "") s0_legacy_key = addon_config.get( CONF_ADDON_S0_LEGACY_KEY, self.s0_legacy_key or "" ) @@ -1237,24 +1263,42 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.error("Failed to get USB ports: %s", err) return self.async_abort(reason="usb_ports_failed") + # Insert empty option in ports to allow setting a socket + ports = { + "": "Use Socket", + **ports, + } + data_schema = vol.Schema( { - vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports), - vol.Optional(CONF_S0_LEGACY_KEY, default=s0_legacy_key): str, vol.Optional( - CONF_S2_ACCESS_CONTROL_KEY, default=s2_access_control_key + CONF_USB_PATH, description={"suggested_value": usb_path} + ): vol.In(ports), + vol.Optional( + CONF_SOCKET_PATH, description={"suggested_value": socket_path} ): str, vol.Optional( - CONF_S2_AUTHENTICATED_KEY, default=s2_authenticated_key + CONF_S0_LEGACY_KEY, description={"suggested_value": s0_legacy_key} ): str, vol.Optional( - CONF_S2_UNAUTHENTICATED_KEY, default=s2_unauthenticated_key + CONF_S2_ACCESS_CONTROL_KEY, + description={"suggested_value": s2_access_control_key}, ): str, vol.Optional( - CONF_LR_S2_ACCESS_CONTROL_KEY, default=lr_s2_access_control_key + CONF_S2_AUTHENTICATED_KEY, + description={"suggested_value": s2_authenticated_key}, ): str, vol.Optional( - CONF_LR_S2_AUTHENTICATED_KEY, default=lr_s2_authenticated_key + CONF_S2_UNAUTHENTICATED_KEY, + description={"suggested_value": s2_unauthenticated_key}, + ): str, + vol.Optional( + CONF_LR_S2_ACCESS_CONTROL_KEY, + description={"suggested_value": lr_s2_access_control_key}, + ): str, + vol.Optional( + CONF_LR_S2_AUTHENTICATED_KEY, + description={"suggested_value": lr_s2_authenticated_key}, ): str, } ) @@ -1268,8 +1312,10 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Choose a serial port.""" if user_input is not None: - self.usb_path = user_input[CONF_USB_PATH] + self.usb_path = user_input.get(CONF_USB_PATH) + self.socket_path = user_input.get(CONF_SOCKET_PATH) self._addon_config_updates[CONF_ADDON_DEVICE] = self.usb_path + self._addon_config_updates[CONF_ADDON_SOCKET] = self.socket_path return await self.async_step_start_addon() try: @@ -1286,10 +1332,16 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): await self.hass.async_add_executor_job(usb.get_serial_by_id, old_usb_path), None, ) + # Insert empty option in ports to allow setting a socket + ports = { + "": "Use Socket", + **ports, + } data_schema = vol.Schema( { - vol.Required(CONF_USB_PATH): vol.In(ports), + vol.Optional(CONF_USB_PATH): vol.In(ports), + vol.Optional(CONF_SOCKET_PATH): str, } ) return self.async_show_form( @@ -1347,6 +1399,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): **config_entry.data, CONF_URL: ws_address, CONF_USB_PATH: self.usb_path, + CONF_SOCKET_PATH: self.socket_path, CONF_S0_LEGACY_KEY: self.s0_legacy_key, CONF_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, CONF_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, @@ -1396,6 +1449,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): { CONF_URL: self.ws_address, CONF_USB_PATH: self.usb_path, + CONF_SOCKET_PATH: self.socket_path, CONF_S0_LEGACY_KEY: self.s0_legacy_key, CONF_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, CONF_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, @@ -1409,6 +1463,30 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="reconfigure_successful") + async def async_step_esphome( + self, discovery_info: ESPHomeServiceInfo + ) -> ConfigFlowResult: + """Handle a ESPHome discovery.""" + if not is_hassio(self.hass): + return self.async_abort(reason="not_hassio") + + if discovery_info.zwave_home_id: + await self.async_set_unique_id(str(discovery_info.zwave_home_id)) + self._abort_if_unique_id_configured( + { + CONF_USB_PATH: None, + CONF_SOCKET_PATH: discovery_info.socket_path, + } + ) + + self.socket_path = discovery_info.socket_path + self.context["title_placeholders"] = { + CONF_NAME: f"{discovery_info.name} via ESPHome" + } + self._adapter_discovered = True + + return await self.async_step_installation_type() + async def async_revert_addon_config(self, reason: str) -> ConfigFlowResult: """Abort the options flow. diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 69987385d5a0..951f312516d6 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -12,6 +12,7 @@ from zwave_js_server.const.command_class.window_covering import ( from homeassistant.const import APPLICATION_NAME, __version__ as HA_VERSION LR_ADDON_VERSION = AwesomeVersion("0.5.0") +ESPHOME_ADDON_VERSION = AwesomeVersion("0.24.0") USER_AGENT = {APPLICATION_NAME: HA_VERSION} @@ -23,6 +24,7 @@ CONF_ADDON_S2_AUTHENTICATED_KEY = "s2_authenticated_key" CONF_ADDON_S2_UNAUTHENTICATED_KEY = "s2_unauthenticated_key" CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY = "lr_s2_access_control_key" CONF_ADDON_LR_S2_AUTHENTICATED_KEY = "lr_s2_authenticated_key" +CONF_ADDON_SOCKET = "socket" CONF_INSTALLER_MODE = "installer_mode" CONF_INTEGRATION_CREATED_ADDON = "integration_created_addon" CONF_KEEP_OLD_DEVICES = "keep_old_devices" @@ -33,6 +35,7 @@ CONF_S2_AUTHENTICATED_KEY = "s2_authenticated_key" CONF_S2_UNAUTHENTICATED_KEY = "s2_unauthenticated_key" CONF_LR_S2_ACCESS_CONTROL_KEY = "lr_s2_access_control_key" CONF_LR_S2_AUTHENTICATED_KEY = "lr_s2_authenticated_key" +CONF_SOCKET_PATH = "socket_path" CONF_USB_PATH = "usb_path" CONF_USE_ADDON = "use_addon" CONF_DATA_COLLECTION_OPTED_IN = "data_collection_opted_in" diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index cf2d644da1b2..70ea973c3c81 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -21,7 +21,8 @@ "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on.", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "reset_failed": "Failed to reset adapter.", - "usb_ports_failed": "Failed to get USB devices." + "usb_ports_failed": "Failed to get USB devices.", + "not_hassio": "ESPHome discovery requires Home Assistant to configure the Z-Wave add-on." }, "error": { "addon_start_failed": "Failed to start the Z-Wave add-on. Check the configuration.", diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 27e1928ef078..9612868383ea 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -100,6 +100,7 @@ _LOGGER = logging.getLogger(__name__) SOURCE_BLUETOOTH = "bluetooth" SOURCE_DHCP = "dhcp" SOURCE_DISCOVERY = "discovery" +SOURCE_ESPHOME = "esphome" SOURCE_HARDWARE = "hardware" SOURCE_HASSIO = "hassio" SOURCE_HOMEKIT = "homekit" @@ -2336,8 +2337,9 @@ class ConfigEntries: entry: ConfigEntry, *, data: Mapping[str, Any] | UndefinedType = UNDEFINED, - discovery_keys: MappingProxyType[str, tuple[DiscoveryKey, ...]] - | UndefinedType = UNDEFINED, + discovery_keys: ( + MappingProxyType[str, tuple[DiscoveryKey, ...]] | UndefinedType + ) = UNDEFINED, minor_version: int | UndefinedType = UNDEFINED, options: Mapping[str, Any] | UndefinedType = UNDEFINED, pref_disable_new_entities: bool | UndefinedType = UNDEFINED, @@ -2373,8 +2375,9 @@ class ConfigEntries: entry: ConfigEntry, *, data: Mapping[str, Any] | UndefinedType = UNDEFINED, - discovery_keys: MappingProxyType[str, tuple[DiscoveryKey, ...]] - | UndefinedType = UNDEFINED, + discovery_keys: ( + MappingProxyType[str, tuple[DiscoveryKey, ...]] | UndefinedType + ) = UNDEFINED, minor_version: int | UndefinedType = UNDEFINED, options: Mapping[str, Any] | UndefinedType = UNDEFINED, pref_disable_new_entities: bool | UndefinedType = UNDEFINED, @@ -2728,7 +2731,10 @@ class ConfigEntries: continue issues.add(issue.issue_id) - for domain, unique_ids in self._entries._domain_unique_id_index.items(): # noqa: SLF001 + for ( + domain, + unique_ids, + ) in self._entries._domain_unique_id_index.items(): # noqa: SLF001 for unique_id, entries in unique_ids.items(): # We might mutate the list of entries, so we need a copy to not mess up # the index diff --git a/homeassistant/helpers/service_info/esphome.py b/homeassistant/helpers/service_info/esphome.py new file mode 100644 index 000000000000..5a9d50baaec6 --- /dev/null +++ b/homeassistant/helpers/service_info/esphome.py @@ -0,0 +1,26 @@ +"""ESPHome discovery data.""" + +from dataclasses import dataclass + +from yarl import URL + +from homeassistant.data_entry_flow import BaseServiceInfo + + +@dataclass(slots=True) +class ESPHomeServiceInfo(BaseServiceInfo): + """Prepared info from ESPHome entries.""" + + name: str + zwave_home_id: int | None + ip_address: str + port: int + noise_psk: str | None = None + + @property + def socket_path(self) -> str: + """Return the socket path to connect to the ESPHome device.""" + url = URL.build(scheme="esphome", host=self.ip_address, port=self.port) + if self.noise_psk: + url = url.with_user(self.noise_psk) + return str(url) diff --git a/tests/components/esphome/test_entry_data.py b/tests/components/esphome/test_entry_data.py index 044c3c7a8f16..a80c77eb5b2b 100644 --- a/tests/components/esphome/test_entry_data.py +++ b/tests/components/esphome/test_entry_data.py @@ -1,5 +1,7 @@ """Test ESPHome entry data.""" +from unittest.mock import Mock, patch + from aioesphomeapi import ( APIClient, EntityCategory as ESPHomeEntityCategory, @@ -8,9 +10,11 @@ from aioesphomeapi import ( ) from homeassistant.components.esphome import DOMAIN +from homeassistant.components.esphome.entry_data import RuntimeEntryData from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import discovery_flow, entity_registry as er +from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo from .conftest import MockGenericDeviceEntryType @@ -69,3 +73,50 @@ async def test_migrate_entity_unique_id_downgrade_upgrade( # 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_discover_zwave() -> None: + """Test ESPHome discovery of Z-Wave JS.""" + hass = Mock() + entry_data = RuntimeEntryData( + "mock-id", + "mock-title", + Mock( + connected_address="mock-client-address", + port=1234, + noise_psk=None, + ), + None, + ) + device_info = Mock( + mac_address="mock-device-info-mac", + zwave_proxy_feature_flags=1, + zwave_home_id=1234, + ) + device_info.name = "mock-device-infoname" + + with patch( + "homeassistant.helpers.discovery_flow.async_create_flow" + ) as mock_create_flow: + entry_data.async_on_connect( + hass, + device_info, + None, + ) + mock_create_flow.assert_called_once_with( + hass, + "zwave_js", + {"source": "esphome"}, + ESPHomeServiceInfo( + name="mock-device-infoname", + zwave_home_id=1234, + ip_address="mock-client-address", + port=1234, + noise_psk=None, + ), + discovery_key=discovery_flow.DiscoveryKey( + domain="esphome", + key="mock-device-info-mac", + version=1, + ), + ) diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index bab13666a290..42bad7e0f55e 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -29,6 +29,8 @@ from homeassistant.components.zwave_js.const import ( CONF_ADDON_S2_ACCESS_CONTROL_KEY, CONF_ADDON_S2_AUTHENTICATED_KEY, CONF_ADDON_S2_UNAUTHENTICATED_KEY, + CONF_ADDON_SOCKET, + CONF_SOCKET_PATH, CONF_USB_PATH, DOMAIN, ) @@ -36,6 +38,7 @@ from homeassistant.components.zwave_js.helpers import SERVER_VERSION_TIMEOUT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -49,6 +52,13 @@ ADDON_DISCOVERY_INFO = { } +ESPHOME_DISCOVERY_INFO = ESPHomeServiceInfo( + name="mock-name", + zwave_home_id=1234, + ip_address="192.168.1.100", + port=6053, +) + USB_DISCOVERY_INFO = UsbServiceInfo( device="/dev/zwave", pid="AAAA", @@ -239,6 +249,7 @@ async def test_manual(hass: HomeAssistant) -> None: assert result2["data"] == { "url": "ws://localhost:3000", "usb_path": None, + "socket_path": None, "s0_legacy_key": None, "s2_access_control_key": None, "s2_authenticated_key": None, @@ -433,6 +444,7 @@ async def test_supervisor_discovery( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", + "socket_path": None, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -539,6 +551,7 @@ async def test_clean_discovery_on_user_create( assert result["data"] == { "url": "ws://localhost:3000", "usb_path": None, + "socket_path": None, "s0_legacy_key": None, "s2_access_control_key": None, "s2_authenticated_key": None, @@ -754,6 +767,7 @@ async def test_usb_discovery( assert result["data"] == { "url": "ws://host1:3001", "usb_path": device, + "socket_path": None, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -866,6 +880,7 @@ async def test_usb_discovery_addon_not_running( assert result["data"] == { "url": "ws://host1:3001", "usb_path": USB_DISCOVERY_INFO.device, + "socket_path": None, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -976,7 +991,12 @@ async def test_usb_discovery_migration( assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "start_addon" assert set_addon_options.call_args == call( - "core_zwave_js", AddonsOptions(config={"device": USB_DISCOVERY_INFO.device}) + "core_zwave_js", + AddonsOptions( + config={ + CONF_ADDON_DEVICE: USB_DISCOVERY_INFO.device, + } + ), ) await hass.async_block_till_done() @@ -1006,6 +1026,7 @@ async def test_usb_discovery_migration( assert result["reason"] == "migration_successful" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == USB_DISCOVERY_INFO.device + assert entry.data["socket_path"] is None assert entry.data["use_addon"] is True assert "keep_old_devices" not in entry.data assert entry.unique_id == "3245146787" @@ -1104,7 +1125,12 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "start_addon" assert set_addon_options.call_args == call( - "core_zwave_js", AddonsOptions(config={"device": USB_DISCOVERY_INFO.device}) + "core_zwave_js", + AddonsOptions( + config={ + "device": USB_DISCOVERY_INFO.device, + } + ), ) await hass.async_block_till_done() @@ -1135,11 +1161,135 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( assert result["reason"] == "migration_successful" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == USB_DISCOVERY_INFO.device + assert entry.data["socket_path"] is None assert entry.data["use_addon"] is True assert entry.unique_id == "1234" assert "keep_old_devices" in entry.data +@pytest.mark.usefixtures("supervisor", "addon_not_installed", "addon_info") +async def test_esphome_discovery( + hass: HomeAssistant, + install_addon: AsyncMock, + set_addon_options: AsyncMock, + start_addon: AsyncMock, +) -> None: + """Test ESPHome discovery success path.""" + # Make sure it works only on hassio + with patch( + "homeassistant.components.zwave_js.config_flow.is_hassio", return_value=False + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "not_hassio" + + # Test working version + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO, + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + assert result["menu_options"] == ["intent_recommended", "intent_custom"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_custom"} + ) + + assert result["step_id"] == "install_addon" + assert result["type"] is FlowResultType.SHOW_PROGRESS + + # Make sure the flow continues when the progress task is done. + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert install_addon.call_args == call("core_zwave_js") + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + }, + ) + + assert set_addon_options.call_args == call( + "core_zwave_js", + AddonsOptions( + config={ + "socket": "esphome://192.168.1.100:6053", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + } + ), + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + + with ( + patch( + "homeassistant.components.zwave_js.async_setup", return_value=True + ) as mock_setup, + patch( + "homeassistant.components.zwave_js.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert start_addon.call_args == call("core_zwave_js") + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TITLE + assert result["result"].unique_id == str(ESPHOME_DISCOVERY_INFO.zwave_home_id) + assert result["data"] == { + "url": "ws://host1:3001", + "usb_path": None, + "socket_path": "esphome://192.168.1.100:6053", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + "use_addon": True, + "integration_created_addon": True, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + @pytest.mark.usefixtures("supervisor", "addon_installed") async def test_discovery_addon_not_running( hass: HomeAssistant, @@ -1239,6 +1389,7 @@ async def test_discovery_addon_not_running( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", + "socket_path": None, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1358,6 +1509,7 @@ async def test_discovery_addon_not_installed( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", + "socket_path": None, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1552,6 +1704,7 @@ async def test_not_addon(hass: HomeAssistant) -> None: assert result["data"] == { "url": "ws://localhost:3000", "usb_path": None, + "socket_path": None, "s0_legacy_key": None, "s2_access_control_key": None, "s2_authenticated_key": None, @@ -1612,6 +1765,7 @@ async def test_addon_running( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", + "socket_path": None, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1772,6 +1926,7 @@ async def test_addon_running_already_configured( assert result["reason"] == "already_configured" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == "/test_new" + assert entry.data["socket_path"] is None assert entry.data["s0_legacy_key"] == "new123" assert entry.data["s2_access_control_key"] == "new456" assert entry.data["s2_authenticated_key"] == "new789" @@ -1879,6 +2034,7 @@ async def test_addon_installed( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", + "socket_path": None, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -2307,6 +2463,7 @@ async def test_addon_installed_already_configured( assert result["reason"] == "already_configured" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == "/new" + assert entry.data["socket_path"] is None assert entry.data["s0_legacy_key"] == "new123" assert entry.data["s2_access_control_key"] == "new456" assert entry.data["s2_authenticated_key"] == "new789" @@ -2424,6 +2581,7 @@ async def test_addon_not_installed( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", + "socket_path": None, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -2717,6 +2875,7 @@ async def test_reconfigure_not_addon_with_addon_stop_fail( ( "entry_data", "old_addon_options", + "form_data", "new_addon_options", "disconnect_calls", ), @@ -2742,6 +2901,15 @@ async def test_reconfigure_not_addon_with_addon_stop_fail( "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", }, + { + "device": "/new", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + }, 0, ), ( @@ -2765,6 +2933,15 @@ async def test_reconfigure_not_addon_with_addon_stop_fail( "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", }, + { + "device": "/new", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + }, 1, ), ], @@ -2778,6 +2955,7 @@ async def test_reconfigure_addon_running( restart_addon: AsyncMock, entry_data: dict[str, Any], old_addon_options: dict[str, Any], + form_data: dict[str, Any], new_addon_options: dict[str, Any], disconnect_calls: int, ) -> None: @@ -2812,11 +2990,9 @@ async def test_reconfigure_addon_running( assert result["step_id"] == "configure_addon_reconfigure" result = await hass.config_entries.flow.async_configure( - result["flow_id"], - new_addon_options, + result["flow_id"], form_data ) - new_addon_options["device"] = new_addon_options.pop("usb_path") assert set_addon_options.call_args == call( "core_zwave_js", AddonsOptions(config=new_addon_options), @@ -2835,7 +3011,8 @@ async def test_reconfigure_addon_running( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" assert entry.data["url"] == "ws://host1:3001" - assert entry.data["usb_path"] == new_addon_options["device"] + assert entry.data["usb_path"] == new_addon_options.get("device") + assert entry.data["socket_path"] == new_addon_options.get("socket") assert entry.data["s0_legacy_key"] == new_addon_options["s0_legacy_key"] assert ( entry.data["s2_access_control_key"] @@ -2864,7 +3041,7 @@ async def test_reconfigure_addon_running( @pytest.mark.usefixtures("supervisor", "addon_running") @pytest.mark.parametrize( - ("entry_data", "old_addon_options", "new_addon_options"), + ("entry_data", "old_addon_options", "form_data", "new_addon_options"), [ ( {}, @@ -2887,6 +3064,15 @@ async def test_reconfigure_addon_running( "lr_s2_access_control_key": "old654", "lr_s2_authenticated_key": "old321", }, + { + "device": "/test", + "s0_legacy_key": "old123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", + "lr_s2_access_control_key": "old654", + "lr_s2_authenticated_key": "old321", + }, ), ], ) @@ -2899,6 +3085,7 @@ async def test_reconfigure_addon_running_no_changes( restart_addon: AsyncMock, entry_data: dict[str, Any], old_addon_options: dict[str, Any], + form_data: dict[str, Any], new_addon_options: dict[str, Any], ) -> None: """Test reconfigure flow without changes, and add-on already running on Supervisor.""" @@ -2932,19 +3119,18 @@ async def test_reconfigure_addon_running_no_changes( assert result["step_id"] == "configure_addon_reconfigure" result = await hass.config_entries.flow.async_configure( - result["flow_id"], - new_addon_options, + result["flow_id"], form_data ) await hass.async_block_till_done() - new_addon_options["device"] = new_addon_options.pop("usb_path") assert set_addon_options.call_count == 0 assert restart_addon.call_count == 0 assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" assert entry.data["url"] == "ws://host1:3001" - assert entry.data["usb_path"] == new_addon_options["device"] + assert entry.data["usb_path"] == new_addon_options.get("device") + assert entry.data["socket_path"] == new_addon_options.get("socket") assert entry.data["s0_legacy_key"] == new_addon_options["s0_legacy_key"] assert ( entry.data["s2_access_control_key"] @@ -2987,6 +3173,7 @@ async def different_device_server_version(*args): ( "entry_data", "old_addon_options", + "form_data", "new_addon_options", "disconnect_calls", "server_version_side_effect", @@ -3013,6 +3200,48 @@ async def different_device_server_version(*args): "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", }, + { + "device": "/new", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + }, + 0, + different_device_server_version, + ), + ( + {}, + { + "device": "/test", + "network_key": "old123", + "s0_legacy_key": "old123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", + "lr_s2_access_control_key": "old654", + "lr_s2_authenticated_key": "old321", + }, + { + "socket_path": "esphome://mock-host:6053", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + }, + { + "socket": "esphome://mock-host:6053", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + }, 0, different_device_server_version, ), @@ -3027,6 +3256,7 @@ async def test_reconfigure_different_device( restart_addon: AsyncMock, entry_data: dict[str, Any], old_addon_options: dict[str, Any], + form_data: dict[str, Any], new_addon_options: dict[str, Any], disconnect_calls: int, ) -> None: @@ -3062,12 +3292,10 @@ async def test_reconfigure_different_device( assert result["step_id"] == "configure_addon_reconfigure" result = await hass.config_entries.flow.async_configure( - result["flow_id"], - new_addon_options, + result["flow_id"], form_data ) assert set_addon_options.call_count == 1 - new_addon_options["device"] = new_addon_options.pop("usb_path") assert set_addon_options.call_args == call( "core_zwave_js", AddonsOptions(config=new_addon_options) ) @@ -3114,6 +3342,7 @@ async def test_reconfigure_different_device( ( "entry_data", "old_addon_options", + "form_data", "new_addon_options", "disconnect_calls", "restart_addon_side_effect", @@ -3140,6 +3369,15 @@ async def test_reconfigure_different_device( "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", }, + { + "device": "/new", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + }, 0, [SupervisorError(), None], ), @@ -3164,6 +3402,15 @@ async def test_reconfigure_different_device( "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", }, + { + "device": "/new", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + }, 0, [ SupervisorError(), @@ -3181,6 +3428,7 @@ async def test_reconfigure_addon_restart_failed( restart_addon: AsyncMock, entry_data: dict[str, Any], old_addon_options: dict[str, Any], + form_data: dict[str, Any], new_addon_options: dict[str, Any], disconnect_calls: int, ) -> None: @@ -3216,12 +3464,10 @@ async def test_reconfigure_addon_restart_failed( assert result["step_id"] == "configure_addon_reconfigure" result = await hass.config_entries.flow.async_configure( - result["flow_id"], - new_addon_options, + result["flow_id"], form_data ) assert set_addon_options.call_count == 1 - new_addon_options["device"] = new_addon_options.pop("usb_path") assert set_addon_options.call_args == call( "core_zwave_js", AddonsOptions(config=new_addon_options) ) @@ -3319,8 +3565,7 @@ async def test_reconfigure_addon_running_server_info_failure( assert result["step_id"] == "configure_addon_reconfigure" result = await hass.config_entries.flow.async_configure( - result["flow_id"], - new_addon_options, + result["flow_id"], new_addon_options ) await hass.async_block_till_done() @@ -3337,6 +3582,7 @@ async def test_reconfigure_addon_running_server_info_failure( ( "entry_data", "old_addon_options", + "form_data", "new_addon_options", "disconnect_calls", ), @@ -3362,6 +3608,15 @@ async def test_reconfigure_addon_running_server_info_failure( "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", }, + { + "device": "/new", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + }, 0, ), ( @@ -3385,6 +3640,15 @@ async def test_reconfigure_addon_running_server_info_failure( "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", }, + { + "device": "/new", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + }, 1, ), ], @@ -3399,6 +3663,7 @@ async def test_reconfigure_addon_not_installed( start_addon: AsyncMock, entry_data: dict[str, Any], old_addon_options: dict[str, Any], + form_data: dict[str, Any], new_addon_options: dict[str, Any], disconnect_calls: int, ) -> None: @@ -3443,11 +3708,9 @@ async def test_reconfigure_addon_not_installed( assert result["step_id"] == "configure_addon_reconfigure" result = await hass.config_entries.flow.async_configure( - result["flow_id"], - new_addon_options, + result["flow_id"], form_data ) - new_addon_options["device"] = new_addon_options.pop("usb_path") assert set_addon_options.call_args == call( "core_zwave_js", AddonsOptions(config=new_addon_options) ) @@ -3468,7 +3731,7 @@ async def test_reconfigure_addon_not_installed( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" assert entry.data["url"] == "ws://host1:3001" - assert entry.data["usb_path"] == new_addon_options["device"] + assert entry.data["usb_path"] == new_addon_options.get("device") assert entry.data["s0_legacy_key"] == new_addon_options["s0_legacy_key"] assert entry.data["use_addon"] is True assert entry.data["integration_created_addon"] is True @@ -3513,6 +3776,7 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert result["data"] == { "url": "ws://127.0.0.1:3000", "usb_path": None, + "socket_path": None, "s0_legacy_key": None, "s2_access_control_key": None, "s2_authenticated_key": None, @@ -3578,14 +3842,30 @@ async def test_reconfigure_migrate_low_sdk_version( @pytest.mark.usefixtures("supervisor", "addon_running") @pytest.mark.parametrize( ( + "form_data", + "new_addon_options", "restore_server_version_side_effect", "final_unique_id", "keep_old_devices", "device_entry_count", ), [ - (None, "3245146787", False, 2), - (aiohttp.ClientError("Boom"), "5678", True, 4), + ( + {CONF_USB_PATH: "/test"}, + {CONF_ADDON_DEVICE: "/test"}, + None, + "3245146787", + False, + 2, + ), + ( + {CONF_SOCKET_PATH: "esphome://1.2.3.4:1234"}, + {CONF_ADDON_SOCKET: "esphome://1.2.3.4:1234"}, + aiohttp.ClientError("Boom"), + "5678", + True, + 4, + ), ], ) async def test_reconfigure_migrate_with_addon( @@ -3598,6 +3878,8 @@ async def test_reconfigure_migrate_with_addon( addon_options: dict[str, Any], set_addon_options: AsyncMock, get_server_version: AsyncMock, + form_data: dict[str, Any], + new_addon_options: dict, restore_server_version_side_effect: Exception | None, final_unique_id: str, keep_old_devices: bool, @@ -3714,26 +3996,17 @@ async def test_reconfigure_migrate_with_addon( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "choose_serial_port" - data_schema = result["data_schema"] - assert data_schema is not None - assert data_schema.schema[CONF_USB_PATH] - # Ensure the old usb path is not in the list of options - with pytest.raises(InInvalid): - data_schema.schema[CONF_USB_PATH](addon_options["device"]) version_info.home_id = 5678 result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_USB_PATH: "/test", - }, + result["flow_id"], form_data ) assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "start_addon" assert set_addon_options.call_args == call( - "core_zwave_js", AddonsOptions(config={"device": "/test"}) + "core_zwave_js", AddonsOptions(config=new_addon_options) ) # Simulate the new connected controller hardware labels. @@ -3751,17 +4024,19 @@ async def test_reconfigure_migrate_with_addon( assert restart_addon.call_args == call("core_zwave_js") - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + # Ensure add-on running would migrate the old settings back into the config entry + with patch("homeassistant.components.zwave_js.async_ensure_addon_running"): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert entry.unique_id == "5678" - get_server_version.side_effect = restore_server_version_side_effect - version_info.home_id = 3245146787 + assert entry.unique_id == "5678" + get_server_version.side_effect = restore_server_version_side_effect + version_info.home_id = 3245146787 - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "restore_nvm" - assert client.connect.call_count == 2 + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "restore_nvm" + assert client.connect.call_count == 2 - await hass.async_block_till_done() + await hass.async_block_till_done() assert client.connect.call_count == 4 assert entry.state is config_entries.ConfigEntryState.LOADED assert client.driver.controller.async_restore_nvm.call_count == 1 @@ -3774,7 +4049,8 @@ async def test_reconfigure_migrate_with_addon( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "migration_successful" assert entry.data["url"] == "ws://host1:3001" - assert entry.data["usb_path"] == "/test" + assert entry.data[CONF_USB_PATH] == new_addon_options.get(CONF_ADDON_DEVICE) + assert entry.data[CONF_SOCKET_PATH] == new_addon_options.get(CONF_ADDON_SOCKET) assert entry.data["use_addon"] is True assert ("keep_old_devices" in entry.data) is keep_old_devices assert entry.unique_id == final_unique_id @@ -3931,6 +4207,7 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( assert result["reason"] == "migration_successful" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == "/test" + assert entry.data["socket_path"] is None assert entry.data["use_addon"] is True assert "keep_old_devices" in entry.data assert entry.unique_id == "1234" @@ -4443,8 +4720,9 @@ async def test_intent_recommended_user( assert result["step_id"] == "configure_addon_user" data_schema = result["data_schema"] assert data_schema is not None - assert len(data_schema.schema) == 1 + assert len(data_schema.schema) == 2 assert data_schema.schema.get(CONF_USB_PATH) is not None + assert data_schema.schema.get(CONF_SOCKET_PATH) is not None result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -4491,6 +4769,7 @@ async def test_intent_recommended_user( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", + "socket_path": None, "s0_legacy_key": "", "s2_access_control_key": "", "s2_authenticated_key": "", @@ -4601,6 +4880,7 @@ async def test_recommended_usb_discovery( assert result["data"] == { "url": "ws://host1:3001", "usb_path": device, + "socket_path": None, "s0_legacy_key": "", "s2_access_control_key": "", "s2_authenticated_key": "", @@ -4860,6 +5140,7 @@ async def test_addon_rf_region_migrate_network( assert result["reason"] == "migration_successful" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == "/test" + assert entry.data["socket_path"] is None assert entry.data["use_addon"] is True assert entry.unique_id == "3245146787" assert client.driver.controller.home_id == 3245146787 diff --git a/tests/helpers/test_service_info.py b/tests/helpers/test_service_info.py index 249ceb0e6378..ecc017c729ec 100644 --- a/tests/helpers/test_service_info.py +++ b/tests/helpers/test_service_info.py @@ -3,6 +3,7 @@ import pytest from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo # Ensure that incorrectly formatted mac addresses are rejected, even # on a constant outside of a test @@ -21,3 +22,16 @@ def test_invalid_macaddress() -> None: """Test that DhcpServiceInfo raises ValueError for unformatted macaddress.""" with pytest.raises(ValueError): DhcpServiceInfo(ip="", hostname="", macaddress="AA:BB:CC:DD:EE:FF") + + +def test_esphome_socket_path() -> None: + """Test ESPHomeServiceInfo socket_path property.""" + info = ESPHomeServiceInfo( + name="Hello World", + zwave_home_id=123456789, + ip_address="192.168.1.100", + port=6053, + ) + assert info.socket_path == "esphome://192.168.1.100:6053" + info.noise_psk = "my-noise-psk" + assert info.socket_path == "esphome://my-noise-psk@192.168.1.100:6053" From 25806615a944206be9f339c873cf9a99011a6ffc Mon Sep 17 00:00:00 2001 From: cdnninja Date: Tue, 23 Sep 2025 05:00:59 -0600 Subject: [PATCH 056/189] Bump pyvesync to 3.0.0 (#152726) --- homeassistant/components/vesync/fan.py | 4 +- homeassistant/components/vesync/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/vesync/snapshots/test_fan.ambr | 52 +++++++++---------- 5 files changed, 31 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 834f8c89ed06..0c28faac59f8 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -118,7 +118,7 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): if hasattr(self.device, "modes"): return sorted( [ - mode + mode.value for mode in self.device.modes if mode in VS_FAN_MODE_PRESET_LIST_HA ] @@ -141,7 +141,7 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): attr["active_time"] = self.device.state.active_time if hasattr(self.device.state, "display_status"): - attr["display_status"] = self.device.state.display_status + attr["display_status"] = self.device.state.display_status.value if hasattr(self.device.state, "child_lock"): attr["child_lock"] = self.device.state.child_lock diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index ef423796f32a..6ea7edd13d51 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", "loggers": ["pyvesync"], - "requirements": ["pyvesync==3.0.0b8"] + "requirements": ["pyvesync==3.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1a6649fa5587..5301f94f3548 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2598,7 +2598,7 @@ pyvera==0.3.16 pyversasense==0.0.6 # homeassistant.components.vesync -pyvesync==3.0.0b8 +pyvesync==3.0.0 # homeassistant.components.vizio pyvizio==0.1.61 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f28c7b5bcf5..483ba088a22d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2159,7 +2159,7 @@ pyuptimerobot==22.2.0 pyvera==0.3.16 # homeassistant.components.vesync -pyvesync==3.0.0b8 +pyvesync==3.0.0 # homeassistant.components.vizio pyvizio==0.1.61 diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 7dc838ba6d6d..88b6bc64ebb3 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -40,8 +40,8 @@ 'area_id': None, 'capabilities': dict({ 'preset_modes': list([ - , - , + 'auto', + 'sleep', ]), }), 'config_entry_id': , @@ -87,8 +87,8 @@ 'percentage_step': 33.333333333333336, 'preset_mode': 'sleep', 'preset_modes': list([ - , - , + 'auto', + 'sleep', ]), 'supported_features': , }), @@ -141,7 +141,7 @@ 'area_id': None, 'capabilities': dict({ 'preset_modes': list([ - , + 'sleep', ]), }), 'config_entry_id': , @@ -179,7 +179,7 @@ 'attributes': ReadOnlyDict({ 'active_time': None, 'child_lock': False, - 'display_status': , + 'display_status': 'on', 'friendly_name': 'Air Purifier 200s', 'mode': 'manual', 'night_light': , @@ -187,7 +187,7 @@ 'percentage_step': 33.333333333333336, 'preset_mode': None, 'preset_modes': list([ - , + 'sleep', ]), 'supported_features': , }), @@ -240,8 +240,8 @@ 'area_id': None, 'capabilities': dict({ 'preset_modes': list([ - , - , + 'auto', + 'sleep', ]), }), 'config_entry_id': , @@ -279,7 +279,7 @@ 'attributes': ReadOnlyDict({ 'active_time': None, 'child_lock': False, - 'display_status': , + 'display_status': 'on', 'friendly_name': 'Air Purifier 400s', 'mode': 'manual', 'night_light': , @@ -287,8 +287,8 @@ 'percentage_step': 25.0, 'preset_mode': None, 'preset_modes': list([ - , - , + 'auto', + 'sleep', ]), 'supported_features': , }), @@ -341,8 +341,8 @@ 'area_id': None, 'capabilities': dict({ 'preset_modes': list([ - , - , + 'auto', + 'sleep', ]), }), 'config_entry_id': , @@ -380,7 +380,7 @@ 'attributes': ReadOnlyDict({ 'active_time': None, 'child_lock': False, - 'display_status': , + 'display_status': 'on', 'friendly_name': 'Air Purifier 600s', 'mode': 'manual', 'night_light': , @@ -388,8 +388,8 @@ 'percentage_step': 25.0, 'preset_mode': None, 'preset_modes': list([ - , - , + 'auto', + 'sleep', ]), 'supported_features': , }), @@ -627,10 +627,10 @@ 'area_id': None, 'capabilities': dict({ 'preset_modes': list([ - , - , - , - , + 'advancedSleep', + 'auto', + 'normal', + 'turbo', ]), }), 'config_entry_id': , @@ -667,17 +667,17 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'active_time': None, - 'display_status': , + 'display_status': 'off', 'friendly_name': 'SmartTowerFan', 'mode': 'normal', 'percentage': None, 'percentage_step': 8.333333333333334, 'preset_mode': 'normal', 'preset_modes': list([ - , - , - , - , + 'advancedSleep', + 'auto', + 'normal', + 'turbo', ]), 'supported_features': , }), From 86db60c44239ab5371b2f7deb986af8ab1aefb3a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 23 Sep 2025 13:21:59 +0200 Subject: [PATCH 057/189] Freeze time in irm_kmi tests (#152810) --- tests/components/irm_kmi/test_weather.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/irm_kmi/test_weather.py b/tests/components/irm_kmi/test_weather.py index c02a7171c5dd..c563f7b5314f 100644 --- a/tests/components/irm_kmi/test_weather.py +++ b/tests/components/irm_kmi/test_weather.py @@ -37,6 +37,7 @@ async def test_weather_nl( "forecast_type", ["daily", "hourly"], ) +@pytest.mark.freeze_time("2025-09-22T15:30:00+01:00") async def test_forecast_service( hass: HomeAssistant, snapshot: SnapshotAssertion, From 72e608918bfc52ea4c003e82d05cb8e18aefcd7c Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 23 Sep 2025 13:22:16 +0200 Subject: [PATCH 058/189] Handle toggling of the 'expose_to_ha' setting in Music Assistant integration (#152779) --- .../components/music_assistant/__init__.py | 63 ++++++++++---- .../components/music_assistant/const.py | 1 + tests/components/music_assistant/common.py | 6 +- tests/components/music_assistant/test_init.py | 87 ++++++++++++++++++- 4 files changed, 136 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index 32024c5ad139..993d10239966 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -9,8 +9,10 @@ from typing import TYPE_CHECKING from music_assistant_client import MusicAssistantClient from music_assistant_client.exceptions import CannotConnect, InvalidServerVersion +from music_assistant_models.config_entries import PlayerConfig from music_assistant_models.enums import EventType from music_assistant_models.errors import ActionUnavailable, MusicAssistantError +from music_assistant_models.player import Player from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform @@ -25,7 +27,7 @@ from homeassistant.helpers.issue_registry import ( ) from .actions import get_music_assistant_client, register_actions -from .const import DOMAIN, LOGGER +from .const import ATTR_CONF_EXPOSE_PLAYER_TO_HA, DOMAIN, LOGGER if TYPE_CHECKING: from music_assistant_models.event import MassEvent @@ -59,7 +61,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry( +async def async_setup_entry( # noqa: C901 hass: HomeAssistant, entry: MusicAssistantConfigEntry ) -> bool: """Set up Music Assistant from a config entry.""" @@ -126,8 +128,25 @@ async def async_setup_entry( # initialize platforms await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + def add_player(player: Player) -> None: + """Handle adding Player from MA as HA device + entities.""" + entry.runtime_data.discovered_players.add(player.player_id) + # run callback for each platform + for callback in entry.runtime_data.platform_handlers.values(): + callback(player.player_id) + + def remove_player(player_id: str) -> None: + """Handle removing Player from MA as HA device + entities.""" + if player_id in entry.runtime_data.discovered_players: + entry.runtime_data.discovered_players.remove(player_id) + dev_reg = dr.async_get(hass) + if hass_device := dev_reg.async_get_device({(DOMAIN, player_id)}): + dev_reg.async_update_device( + hass_device.id, remove_config_entry_id=entry.entry_id + ) + # register listener for new players - async def handle_player_added(event: MassEvent) -> None: + def handle_player_added(event: MassEvent) -> None: """Handle Mass Player Added event.""" if TYPE_CHECKING: assert event.object_id is not None @@ -138,10 +157,7 @@ async def async_setup_entry( assert player is not None if not player.expose_to_ha: return - entry.runtime_data.discovered_players.add(event.object_id) - # run callback for each platform - for callback in entry.runtime_data.platform_handlers.values(): - callback(event.object_id) + add_player(player) entry.async_on_unload(mass.subscribe(handle_player_added, EventType.PLAYER_ADDED)) @@ -149,25 +165,40 @@ async def async_setup_entry( for player in mass.players: if not player.expose_to_ha: continue - entry.runtime_data.discovered_players.add(player.player_id) - for callback in entry.runtime_data.platform_handlers.values(): - callback(player.player_id) + add_player(player) # register listener for removed players - async def handle_player_removed(event: MassEvent) -> None: + def handle_player_removed(event: MassEvent) -> None: """Handle Mass Player Removed event.""" if event.object_id is None: return - dev_reg = dr.async_get(hass) - if hass_device := dev_reg.async_get_device({(DOMAIN, event.object_id)}): - dev_reg.async_update_device( - hass_device.id, remove_config_entry_id=entry.entry_id - ) + remove_player(event.object_id) entry.async_on_unload( mass.subscribe(handle_player_removed, EventType.PLAYER_REMOVED) ) + # register listener for player configs (to handle toggling of the 'expose_to_ha' setting) + def handle_player_config_updated(event: MassEvent) -> None: + """Handle Mass Player Config Updated event.""" + if event.object_id is None or not event.data: + return + player_id = event.object_id + player_config = PlayerConfig.from_dict(event.data) + expose_to_ha = player_config.get_value(ATTR_CONF_EXPOSE_PLAYER_TO_HA, True) + if not expose_to_ha and player_id in entry.runtime_data.discovered_players: + # player is no longer exposed to Home Assistant + remove_player(player_id) + elif expose_to_ha and player_id not in entry.runtime_data.discovered_players: + # player is now exposed to Home Assistant + if not (player := mass.players.get(player_id)): + return # guard + add_player(player) + + entry.async_on_unload( + mass.subscribe(handle_player_config_updated, EventType.PLAYER_CONFIG_UPDATED) + ) + # check if any playerconfigs have been removed while we were disconnected all_player_configs = await mass.config.get_player_configs() player_ids = {player.player_id for player in all_player_configs} diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py index 8c1701b4afd7..d1a97382193d 100644 --- a/homeassistant/components/music_assistant/const.py +++ b/homeassistant/components/music_assistant/const.py @@ -65,5 +65,6 @@ ATTR_STREAM_TITLE = "stream_title" ATTR_PROVIDER = "provider" ATTR_ITEM_ID = "item_id" +ATTR_CONF_EXPOSE_PLAYER_TO_HA = "expose_player_to_ha" LOGGER = logging.getLogger(__package__) diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index 072b1ece1a19..620a85ed8930 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -186,15 +186,15 @@ async def trigger_subscription_callback( ): continue - event = MassEvent( + mass_event = MassEvent( event=event, object_id=object_id, data=data, ) if inspect.iscoroutinefunction(cb_func): - await cb_func(event) + await cb_func(mass_event) else: - cb_func(event) + cb_func(mass_event) await hass.async_block_till_done() diff --git a/tests/components/music_assistant/test_init.py b/tests/components/music_assistant/test_init.py index 4cfefb50bd23..e088fd202bca 100644 --- a/tests/components/music_assistant/test_init.py +++ b/tests/components/music_assistant/test_init.py @@ -4,14 +4,18 @@ from __future__ import annotations from unittest.mock import AsyncMock, MagicMock +from music_assistant_models.enums import EventType from music_assistant_models.errors import ActionUnavailable -from homeassistant.components.music_assistant.const import DOMAIN +from homeassistant.components.music_assistant.const import ( + ATTR_CONF_EXPOSE_PLAYER_TO_HA, + DOMAIN, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .common import setup_integration_from_fixtures +from .common import setup_integration_from_fixtures, trigger_subscription_callback from tests.typing import WebSocketGenerator @@ -68,3 +72,82 @@ async def test_remove_config_entry_device( response = await client.remove_device(device_entry.id, config_entry.entry_id) assert music_assistant_client.config.remove_player_config.call_count == 0 assert response["success"] is True + + +async def test_player_config_expose_to_ha_toggle( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + music_assistant_client: MagicMock, +) -> None: + """Test player exposure toggle via config update.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + await hass.async_block_till_done() + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + + # Initial state: player should be exposed (from fixture) + entity_id = "media_player.test_player_1" + player_id = "00:00:00:00:00:01" + assert hass.states.get(entity_id) + assert entity_registry.async_get(entity_id) + device_entry = device_registry.async_get_device({(DOMAIN, player_id)}) + assert device_entry + assert player_id in config_entry.runtime_data.discovered_players + + # Simulate player config update: expose_to_ha = False + # Trigger the subscription callback + event_data = { + "player_id": player_id, + "provider": "test", + "values": { + ATTR_CONF_EXPOSE_PLAYER_TO_HA: { + "key": ATTR_CONF_EXPOSE_PLAYER_TO_HA, + "type": "boolean", + "value": False, + "label": ATTR_CONF_EXPOSE_PLAYER_TO_HA, + "default_value": True, + } + }, + } + await trigger_subscription_callback( + hass, + music_assistant_client, + EventType.PLAYER_CONFIG_UPDATED, + player_id, + event_data, + ) + + # Verify player was removed from HA + assert player_id not in config_entry.runtime_data.discovered_players + assert not hass.states.get(entity_id) + assert not entity_registry.async_get(entity_id) + device_entry = device_registry.async_get_device({(DOMAIN, player_id)}) + assert not device_entry + + # Now test re-adding the player: expose_to_ha = True + await trigger_subscription_callback( + hass, + music_assistant_client, + EventType.PLAYER_CONFIG_UPDATED, + player_id, + { + "player_id": player_id, + "provider": "test", + "values": { + ATTR_CONF_EXPOSE_PLAYER_TO_HA: { + "key": ATTR_CONF_EXPOSE_PLAYER_TO_HA, + "type": "boolean", + "value": True, + "label": ATTR_CONF_EXPOSE_PLAYER_TO_HA, + "default_value": True, + } + }, + }, + ) + + # Verify player was re-added to HA + assert player_id in config_entry.runtime_data.discovered_players + assert hass.states.get(entity_id) + assert entity_registry.async_get(entity_id) + device_entry = device_registry.async_get_device({(DOMAIN, player_id)}) + assert device_entry From 9e4a2d5fa91526b635b7ffbf5a86bdc76c12cf01 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 23 Sep 2025 13:39:58 +0200 Subject: [PATCH 059/189] Bump aiohue to 4.8.0 (#152807) --- homeassistant/components/hue/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 04a3a86c0d51..0adc0dfc3b3e 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -10,6 +10,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiohue"], - "requirements": ["aiohue==4.7.5"], + "requirements": ["aiohue==4.8.0"], "zeroconf": ["_hue._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 5301f94f3548..767af02bd67c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -277,7 +277,7 @@ aiohomekit==3.2.18 aiohttp_sse==2.2.0 # homeassistant.components.hue -aiohue==4.7.5 +aiohue==4.8.0 # homeassistant.components.imap aioimaplib==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 483ba088a22d..d08777a74c29 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -262,7 +262,7 @@ aiohomekit==3.2.18 aiohttp_sse==2.2.0 # homeassistant.components.hue -aiohue==4.7.5 +aiohue==4.8.0 # homeassistant.components.imap aioimaplib==2.0.1 From 61153ec4565d3fa18253bc46eadd2dcb78328125 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 23 Sep 2025 13:44:28 +0200 Subject: [PATCH 060/189] Deduplicate code in modbus service call (#152808) Co-authored-by: jan iversen --- homeassistant/components/modbus/modbus.py | 42 +++++++++++------------ 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index e873d53878d2..1f797c82a089 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -169,43 +169,43 @@ async def async_modbus_setup( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_modbus) + def _get_service_call_details( + service: ServiceCall, + ) -> tuple[ModbusHub, int, int]: + """Return the details required to process the service call.""" + device_address = service.data.get(ATTR_SLAVE, service.data.get(ATTR_UNIT, 1)) + address = service.data[ATTR_ADDRESS] + hub = hub_collect[service.data[ATTR_HUB]] + return (hub, device_address, address) + async def async_write_register(service: ServiceCall) -> None: """Write Modbus registers.""" - slave = 1 - if ATTR_UNIT in service.data: - slave = int(float(service.data[ATTR_UNIT])) + hub, device_address, address = _get_service_call_details(service) - if ATTR_SLAVE in service.data: - slave = int(float(service.data[ATTR_SLAVE])) - address = int(float(service.data[ATTR_ADDRESS])) value = service.data[ATTR_VALUE] - hub = hub_collect[service.data.get(ATTR_HUB, DEFAULT_HUB)] if isinstance(value, list): await hub.async_pb_call( - slave, - address, - [int(float(i)) for i in value], - CALL_TYPE_WRITE_REGISTERS, + device_address, address, value, CALL_TYPE_WRITE_REGISTERS ) else: await hub.async_pb_call( - slave, address, int(float(value)), CALL_TYPE_WRITE_REGISTER + device_address, address, value, CALL_TYPE_WRITE_REGISTER ) async def async_write_coil(service: ServiceCall) -> None: """Write Modbus coil.""" - slave = 1 - if ATTR_UNIT in service.data: - slave = int(float(service.data[ATTR_UNIT])) - if ATTR_SLAVE in service.data: - slave = int(float(service.data[ATTR_SLAVE])) - address = service.data[ATTR_ADDRESS] + hub, device_address, address = _get_service_call_details(service) + state = service.data[ATTR_STATE] - hub = hub_collect[service.data.get(ATTR_HUB, DEFAULT_HUB)] + if isinstance(state, list): - await hub.async_pb_call(slave, address, state, CALL_TYPE_WRITE_COILS) + await hub.async_pb_call( + device_address, address, state, CALL_TYPE_WRITE_COILS + ) else: - await hub.async_pb_call(slave, address, state, CALL_TYPE_WRITE_COIL) + await hub.async_pb_call( + device_address, address, state, CALL_TYPE_WRITE_COIL + ) for x_write in ( (SERVICE_WRITE_REGISTER, async_write_register, ATTR_VALUE, cv.positive_int), From 4305ea9b4cc56ef455ad9e7043b2214af27f0414 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 23 Sep 2025 15:16:37 +0200 Subject: [PATCH 061/189] Create analytics platform (#151974) --- .../components/analytics/__init__.py | 15 +- .../components/analytics/analytics.py | 308 +++++++++++++++--- .../components/input_select/analytics.py | 28 ++ tests/components/analytics/test_analytics.py | 125 ++++++- 4 files changed, 421 insertions(+), 55 deletions(-) create mode 100644 homeassistant/components/input_select/analytics.py diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index 83610f0dc75a..4e805814632c 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -12,10 +12,23 @@ from homeassistant.helpers.event import async_call_later, async_track_time_inter from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey -from .analytics import Analytics +from .analytics import ( + Analytics, + AnalyticsInput, + AnalyticsModifications, + DeviceAnalyticsModifications, + EntityAnalyticsModifications, +) from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA from .http import AnalyticsDevicesView +__all__ = [ + "AnalyticsInput", + "AnalyticsModifications", + "DeviceAnalyticsModifications", + "EntityAnalyticsModifications", +] + CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN) diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 60d810e198f3..3a8f2265044b 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -4,9 +4,10 @@ from __future__ import annotations import asyncio from asyncio import timeout -from dataclasses import asdict as dataclass_asdict, dataclass +from collections.abc import Awaitable, Callable, Iterable, Mapping +from dataclasses import asdict as dataclass_asdict, dataclass, field from datetime import datetime -from typing import Any +from typing import Any, Protocol import uuid import aiohttp @@ -35,11 +36,14 @@ from homeassistant.exceptions import HomeAssistantError 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.singleton import singleton from homeassistant.helpers.storage import Store from homeassistant.helpers.system_info import async_get_system_info +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.loader import ( Integration, IntegrationNotFound, + async_get_integration, async_get_integrations, ) from homeassistant.setup import async_get_loaded_integrations @@ -75,12 +79,116 @@ from .const import ( ATTR_USER_COUNT, ATTR_UUID, ATTR_VERSION, + DOMAIN, LOGGER, PREFERENCE_SCHEMA, STORAGE_KEY, STORAGE_VERSION, ) +DATA_ANALYTICS_MODIFIERS = "analytics_modifiers" + +type AnalyticsModifier = Callable[ + [HomeAssistant, AnalyticsInput], Awaitable[AnalyticsModifications] +] + + +@singleton(DATA_ANALYTICS_MODIFIERS) +def _async_get_modifiers( + hass: HomeAssistant, +) -> dict[str, AnalyticsModifier | None]: + """Return the analytics modifiers.""" + return {} + + +@dataclass +class AnalyticsInput: + """Analytics input for a single integration. + + This is sent to integrations that implement the platform. + """ + + device_ids: Iterable[str] = field(default_factory=list) + entity_ids: Iterable[str] = field(default_factory=list) + + +@dataclass +class AnalyticsModifications: + """Analytics config for a single integration. + + This is used by integrations that implement the platform. + """ + + remove: bool = False + devices: Mapping[str, DeviceAnalyticsModifications] | None = None + entities: Mapping[str, EntityAnalyticsModifications] | None = None + + +@dataclass +class DeviceAnalyticsModifications: + """Analytics config for a single device. + + This is used by integrations that implement the platform. + """ + + remove: bool = False + + +@dataclass +class EntityAnalyticsModifications: + """Analytics config for a single entity. + + This is used by integrations that implement the platform. + """ + + remove: bool = False + capabilities: dict[str, Any] | None | UndefinedType = UNDEFINED + + +class AnalyticsPlatformProtocol(Protocol): + """Define the format of analytics platforms.""" + + async def async_modify_analytics( + self, + hass: HomeAssistant, + analytics_input: AnalyticsInput, + ) -> AnalyticsModifications: + """Modify the analytics.""" + + +async def _async_get_analytics_platform( + hass: HomeAssistant, domain: str +) -> AnalyticsPlatformProtocol | None: + """Get analytics platform.""" + try: + integration = await async_get_integration(hass, domain) + except IntegrationNotFound: + return None + try: + return await integration.async_get_platform(DOMAIN) + except ImportError: + return None + + +async def _async_get_modifier( + hass: HomeAssistant, domain: str +) -> AnalyticsModifier | None: + """Get analytics modifier.""" + modifiers = _async_get_modifiers(hass) + modifier = modifiers.get(domain, UNDEFINED) + + if modifier is not UNDEFINED: + return modifier + + platform = await _async_get_analytics_platform(hass, domain) + if platform is None: + modifiers[domain] = None + return None + + modifier = getattr(platform, "async_modify_analytics", None) + modifiers[domain] = modifier + return modifier + def gen_uuid() -> str: """Generate a new UUID.""" @@ -393,17 +501,20 @@ def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]: return domains -async def async_devices_payload(hass: HomeAssistant) -> dict: +DEFAULT_ANALYTICS_CONFIG = AnalyticsModifications() +DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications() +DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications() + + +async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901 """Return detailed information about entities and devices.""" - integrations_info: dict[str, dict[str, Any]] = {} - dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) - # We need to refer to other devices, for example in `via_device` field. - # We don't however send the original device ids outside of Home Assistant, - # instead we refer to devices by (integration_domain, index_in_integration_device_list). - device_id_mapping: dict[str, tuple[str, int]] = {} + integration_inputs: dict[str, tuple[list[str], list[str]]] = {} + integration_configs: dict[str, AnalyticsModifications] = {} + # Get device list for device_entry in dev_reg.devices.values(): if not device_entry.primary_config_entry: continue @@ -416,27 +527,96 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: continue integration_domain = config_entry.domain + + integration_input = integration_inputs.setdefault(integration_domain, ([], [])) + integration_input[0].append(device_entry.id) + + # Get entity list + for entity_entry in ent_reg.entities.values(): + integration_domain = entity_entry.platform + + integration_input = integration_inputs.setdefault(integration_domain, ([], [])) + integration_input[1].append(entity_entry.entity_id) + + # Call integrations that implement the analytics platform + for integration_domain, integration_input in integration_inputs.items(): + if ( + modifier := await _async_get_modifier(hass, integration_domain) + ) is not None: + try: + integration_config = await modifier( + hass, AnalyticsInput(*integration_input) + ) + except Exception as err: # noqa: BLE001 + LOGGER.exception( + "Calling async_modify_analytics for integration '%s' failed: %s", + integration_domain, + err, + ) + integration_configs[integration_domain] = AnalyticsModifications( + remove=True + ) + continue + + if not isinstance(integration_config, AnalyticsModifications): + LOGGER.error( # type: ignore[unreachable] + "Calling async_modify_analytics for integration '%s' did not return an AnalyticsConfig", + integration_domain, + ) + integration_configs[integration_domain] = AnalyticsModifications( + remove=True + ) + continue + + integration_configs[integration_domain] = integration_config + + integrations_info: dict[str, dict[str, Any]] = {} + + # We need to refer to other devices, for example in `via_device` field. + # We don't however send the original device ids outside of Home Assistant, + # instead we refer to devices by (integration_domain, index_in_integration_device_list). + device_id_mapping: dict[str, tuple[str, int]] = {} + + # Fill out information about devices + for integration_domain, integration_input in integration_inputs.items(): + integration_config = integration_configs.get( + integration_domain, DEFAULT_ANALYTICS_CONFIG + ) + + if integration_config.remove: + continue + integration_info = integrations_info.setdefault( integration_domain, {"devices": [], "entities": []} ) devices_info = integration_info["devices"] - device_id_mapping[device_entry.id] = (integration_domain, len(devices_info)) + for device_id in integration_input[0]: + device_config = DEFAULT_DEVICE_ANALYTICS_CONFIG + if integration_config.devices is not None: + device_config = integration_config.devices.get(device_id, device_config) - devices_info.append( - { - "entities": [], - "entry_type": device_entry.entry_type, - "has_configuration_url": device_entry.configuration_url is not None, - "hw_version": device_entry.hw_version, - "manufacturer": device_entry.manufacturer, - "model": device_entry.model, - "model_id": device_entry.model_id, - "sw_version": device_entry.sw_version, - "via_device": device_entry.via_device_id, - } - ) + if device_config.remove: + continue + + device_entry = dev_reg.devices[device_id] + + device_id_mapping[device_entry.id] = (integration_domain, len(devices_info)) + + devices_info.append( + { + "entities": [], + "entry_type": device_entry.entry_type, + "has_configuration_url": device_entry.configuration_url is not None, + "hw_version": device_entry.hw_version, + "manufacturer": device_entry.manufacturer, + "model": device_entry.model, + "model_id": device_entry.model_id, + "sw_version": device_entry.sw_version, + "via_device": device_entry.via_device_id, + } + ) # Fill out via_device with new device ids for integration_info in integrations_info.values(): @@ -445,10 +625,15 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: continue device_info["via_device"] = device_id_mapping.get(device_info["via_device"]) - ent_reg = er.async_get(hass) + # Fill out information about entities + for integration_domain, integration_input in integration_inputs.items(): + integration_config = integration_configs.get( + integration_domain, DEFAULT_ANALYTICS_CONFIG + ) + + if integration_config.remove: + continue - for entity_entry in ent_reg.entities.values(): - integration_domain = entity_entry.platform integration_info = integrations_info.setdefault( integration_domain, {"devices": [], "entities": []} ) @@ -456,35 +641,52 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: devices_info = integration_info["devices"] entities_info = integration_info["entities"] - entity_state = hass.states.get(entity_entry.entity_id) + for entity_id in integration_input[1]: + entity_config = DEFAULT_ENTITY_ANALYTICS_CONFIG + if integration_config.entities is not None: + entity_config = integration_config.entities.get( + entity_id, entity_config + ) - entity_info = { - # LIMITATION: `assumed_state` can be overridden by users; - # we should replace it with the original value in the future. - # It is also not present, if entity is not in the state machine, - # which can happen for disabled entities. - "assumed_state": entity_state.attributes.get(ATTR_ASSUMED_STATE, False) - if entity_state is not None - else None, - "capabilities": entity_entry.capabilities, - "domain": entity_entry.domain, - "entity_category": entity_entry.entity_category, - "has_entity_name": entity_entry.has_entity_name, - "original_device_class": entity_entry.original_device_class, - # LIMITATION: `unit_of_measurement` can be overridden by users; - # we should replace it with the original value in the future. - "unit_of_measurement": entity_entry.unit_of_measurement, - } + if entity_config.remove: + continue - if ( - ((device_id := entity_entry.device_id) is not None) - and ((new_device_id := device_id_mapping.get(device_id)) is not None) - and (new_device_id[0] == integration_domain) - ): - device_info = devices_info[new_device_id[1]] - device_info["entities"].append(entity_info) - else: - entities_info.append(entity_info) + entity_entry = ent_reg.entities[entity_id] + + entity_state = hass.states.get(entity_entry.entity_id) + + entity_info = { + # LIMITATION: `assumed_state` can be overridden by users; + # we should replace it with the original value in the future. + # It is also not present, if entity is not in the state machine, + # which can happen for disabled entities. + "assumed_state": entity_state.attributes.get(ATTR_ASSUMED_STATE, False) + if entity_state is not None + else None, + "capabilities": entity_config.capabilities + if entity_config.capabilities is not UNDEFINED + else entity_entry.capabilities, + "domain": entity_entry.domain, + "entity_category": entity_entry.entity_category, + "has_entity_name": entity_entry.has_entity_name, + "modified_by_integration": ["capabilities"] + if entity_config.capabilities is not UNDEFINED + else None, + "original_device_class": entity_entry.original_device_class, + # LIMITATION: `unit_of_measurement` can be overridden by users; + # we should replace it with the original value in the future. + "unit_of_measurement": entity_entry.unit_of_measurement, + } + + if ( + ((device_id_ := entity_entry.device_id) is not None) + and ((new_device_id := device_id_mapping.get(device_id_)) is not None) + and (new_device_id[0] == integration_domain) + ): + device_info = devices_info[new_device_id[1]] + device_info["entities"].append(entity_info) + else: + entities_info.append(entity_info) integrations = { domain: integration diff --git a/homeassistant/components/input_select/analytics.py b/homeassistant/components/input_select/analytics.py new file mode 100644 index 000000000000..a543b822f47d --- /dev/null +++ b/homeassistant/components/input_select/analytics.py @@ -0,0 +1,28 @@ +"""Analytics platform.""" + +from homeassistant.components.analytics import ( + AnalyticsInput, + AnalyticsModifications, + EntityAnalyticsModifications, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + + +async def async_modify_analytics( + hass: HomeAssistant, analytics_input: AnalyticsInput +) -> AnalyticsModifications: + """Modify the analytics.""" + ent_reg = er.async_get(hass) + + entities: dict[str, EntityAnalyticsModifications] = {} + for entity_id in analytics_input.entity_ids: + entity_entry = ent_reg.entities[entity_id] + if entity_entry.capabilities is not None: + capabilities = dict(entity_entry.capabilities) + capabilities["options"] = len(capabilities["options"]) + entities[entity_id] = EntityAnalyticsModifications( + capabilities=capabilities + ) + + return AnalyticsModifications(entities=entities) diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 30bd2c6d7230..a0bde29979e5 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -13,6 +13,10 @@ from syrupy.matchers import path_type from homeassistant.components.analytics.analytics import ( Analytics, + AnalyticsInput, + AnalyticsModifications, + DeviceAnalyticsModifications, + EntityAnalyticsModifications, async_devices_payload, ) from homeassistant.components.analytics.const import ( @@ -33,7 +37,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.loader import IntegrationNotFound from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, MockModule, mock_integration +from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -1236,6 +1240,7 @@ async def test_devices_payload_with_entities( "domain": "light", "entity_category": None, "has_entity_name": True, + "modified_by_integration": None, "original_device_class": None, "unit_of_measurement": None, }, @@ -1245,6 +1250,7 @@ async def test_devices_payload_with_entities( "domain": "number", "entity_category": "config", "has_entity_name": True, + "modified_by_integration": None, "original_device_class": "temperature", "unit_of_measurement": None, }, @@ -1266,6 +1272,7 @@ async def test_devices_payload_with_entities( "domain": "light", "entity_category": None, "has_entity_name": False, + "modified_by_integration": None, "original_device_class": None, "unit_of_measurement": None, }, @@ -1287,6 +1294,7 @@ async def test_devices_payload_with_entities( "domain": "sensor", "entity_category": None, "has_entity_name": False, + "modified_by_integration": None, "original_device_class": "temperature", "unit_of_measurement": "°C", }, @@ -1302,6 +1310,121 @@ async def test_devices_payload_with_entities( "domain": "light", "entity_category": None, "has_entity_name": True, + "modified_by_integration": None, + "original_device_class": None, + "unit_of_measurement": None, + }, + ], + "is_custom_integration": False, + }, + }, + } + + +async def test_analytics_platforms( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test analytics platforms.""" + assert await async_setup_component(hass, "analytics", {}) + + mock_config_entry = MockConfigEntry(domain="test") + mock_config_entry.add_to_hass(hass) + + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("device", "1")}, + manufacturer="test-manufacturer", + model_id="test-model-id", + ) + 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-2", + ) + + entity_registry.async_get_or_create( + domain="sensor", + platform="test", + unique_id="1", + capabilities={"options": ["secret1", "secret2"]}, + ) + entity_registry.async_get_or_create( + domain="sensor", + platform="test", + unique_id="2", + capabilities={"options": ["secret1", "secret2"]}, + ) + + async def async_modify_analytics( + hass: HomeAssistant, + analytics_input: AnalyticsInput, + ) -> AnalyticsModifications: + first = True + devices_configs = {} + for device_id in analytics_input.device_ids: + device_config = DeviceAnalyticsModifications() + devices_configs[device_id] = device_config + if first: + first = False + else: + device_config.remove = True + + first = True + entities_configs = {} + for entity_id in analytics_input.entity_ids: + entity_entry = entity_registry.async_get(entity_id) + entity_config = EntityAnalyticsModifications() + entities_configs[entity_id] = entity_config + if first: + first = False + entity_config.capabilities = dict(entity_entry.capabilities) + entity_config.capabilities["options"] = len( + entity_config.capabilities["options"] + ) + else: + entity_config.remove = True + + return AnalyticsModifications( + devices=devices_configs, + entities=entities_configs, + ) + + platform_mock = Mock(async_modify_analytics=async_modify_analytics) + mock_platform(hass, "test.analytics", platform_mock) + + client = await hass_client() + response = await client.get("/api/analytics/devices") + assert response.status == HTTPStatus.OK + assert await response.json() == { + "version": "home-assistant:1", + "home_assistant": MOCK_VERSION, + "integrations": { + "test": { + "devices": [ + { + "entities": [], + "entry_type": None, + "has_configuration_url": False, + "hw_version": None, + "manufacturer": "test-manufacturer", + "model": None, + "model_id": "test-model-id", + "sw_version": None, + "via_device": None, + }, + ], + "entities": [ + { + "assumed_state": None, + "capabilities": {"options": 2}, + "domain": "sensor", + "entity_category": None, + "has_entity_name": False, + "modified_by_integration": ["capabilities"], "original_device_class": None, "unit_of_measurement": None, }, From 32688e1108bf8fec206a6dd2761bc4437a864e8c Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 23 Sep 2025 15:29:16 +0200 Subject: [PATCH 062/189] Bump aioacaia to 0.1.17 (#152815) --- homeassistant/components/acaia/coordinator.py | 4 ++++ homeassistant/components/acaia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/acaia/coordinator.py b/homeassistant/components/acaia/coordinator.py index bd915b424081..629e61c395ca 100644 --- a/homeassistant/components/acaia/coordinator.py +++ b/homeassistant/components/acaia/coordinator.py @@ -4,10 +4,13 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import cast from aioacaia.acaiascale import AcaiaScale from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError +from bleak import BleakScanner +from homeassistant.components.bluetooth import async_get_scanner from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant @@ -42,6 +45,7 @@ class AcaiaCoordinator(DataUpdateCoordinator[None]): name=entry.title, is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE], notify_callback=self.async_update_listeners, + scanner=cast(BleakScanner, async_get_scanner(hass)), ) @property diff --git a/homeassistant/components/acaia/manifest.json b/homeassistant/components/acaia/manifest.json index f39511ad41a3..4b2b3da9d752 100644 --- a/homeassistant/components/acaia/manifest.json +++ b/homeassistant/components/acaia/manifest.json @@ -26,5 +26,5 @@ "iot_class": "local_push", "loggers": ["aioacaia"], "quality_scale": "platinum", - "requirements": ["aioacaia==0.1.14"] + "requirements": ["aioacaia==0.1.17"] } diff --git a/requirements_all.txt b/requirements_all.txt index 767af02bd67c..e49466c857c1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -173,7 +173,7 @@ aio-geojson-usgs-earthquakes==0.3 aio-georss-gdacs==0.10 # homeassistant.components.acaia -aioacaia==0.1.14 +aioacaia==0.1.17 # homeassistant.components.airq aioairq==0.4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d08777a74c29..14866be0ea96 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -161,7 +161,7 @@ aio-geojson-usgs-earthquakes==0.3 aio-georss-gdacs==0.10 # homeassistant.components.acaia -aioacaia==0.1.14 +aioacaia==0.1.17 # homeassistant.components.airq aioairq==0.4.6 From da3a164e6696a6ffadf1fd5dd0705bfb3ff82ef6 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Tue, 23 Sep 2025 15:56:13 +0200 Subject: [PATCH 063/189] Change here_travel_time update interval to 30min (#147222) --- .../components/here_travel_time/__init__.py | 32 ++++++++++++++++++- .../components/here_travel_time/sensor.py | 2 +- .../components/here_travel_time/strings.json | 6 ++++ .../components/here_travel_time/test_init.py | 27 ++++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/here_travel_time/__init__.py b/homeassistant/components/here_travel_time/__init__.py index 741a9a1058c8..9de8230e357f 100644 --- a/homeassistant/components/here_travel_time/__init__.py +++ b/homeassistant/components/here_travel_time/__init__.py @@ -6,9 +6,14 @@ import logging from homeassistant.const import CONF_API_KEY, CONF_MODE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.start import async_at_started -from .const import CONF_TRAFFIC_MODE, TRAVEL_MODE_PUBLIC +from .const import CONF_TRAFFIC_MODE, DOMAIN, TRAVEL_MODE_PUBLIC from .coordinator import ( HereConfigEntry, HERERoutingDataUpdateCoordinator, @@ -24,6 +29,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: HereConfigEntry) """Set up HERE Travel Time from a config entry.""" api_key = config_entry.data[CONF_API_KEY] + alert_for_multiple_entries(hass) + cls: type[HERETransitDataUpdateCoordinator | HERERoutingDataUpdateCoordinator] if config_entry.data[CONF_MODE] in {TRAVEL_MODE_PUBLIC, "publicTransportTimeTable"}: cls = HERETransitDataUpdateCoordinator @@ -42,6 +49,29 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: HereConfigEntry) return True +def alert_for_multiple_entries(hass: HomeAssistant) -> None: + """Check if there are multiple entries for the same API key.""" + if len(hass.config_entries.async_entries(DOMAIN)) > 1: + async_create_issue( + hass, + DOMAIN, + "multiple_here_travel_time_entries", + learn_more_url="https://www.home-assistant.io/integrations/here_travel_time/", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="multiple_here_travel_time_entries", + translation_placeholders={ + "pricing_page": "https://www.here.com/get-started/pricing", + }, + ) + else: + async_delete_issue( + hass, + DOMAIN, + "multiple_here_travel_time_entries", + ) + + async def async_unload_entry( hass: HomeAssistant, config_entry: HereConfigEntry ) -> bool: diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index da93c6e301e7..1500006fc397 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -44,7 +44,7 @@ from .coordinator import ( HERETransitDataUpdateCoordinator, ) -SCAN_INTERVAL = timedelta(minutes=5) +SCAN_INTERVAL = timedelta(minutes=30) def sensor_descriptions(travel_mode: str) -> tuple[SensorEntityDescription, ...]: diff --git a/homeassistant/components/here_travel_time/strings.json b/homeassistant/components/here_travel_time/strings.json index 639be3326f9e..95fd77d5fa98 100644 --- a/homeassistant/components/here_travel_time/strings.json +++ b/homeassistant/components/here_travel_time/strings.json @@ -107,5 +107,11 @@ "name": "Destination" } } + }, + "issues": { + "multiple_here_travel_time_entries": { + "title": "More than one HERE Travel Time integration detected", + "description": "HERE deprecated the previous free tier. You have change to the Base Plan which has 5000 instead of 30000 free requests per month.\n\nSince you have more than one HERE Travel Time integration configured, you will need to disable or remove the additional integrations to avoid exceeding the free request limit.\nYou can ignore this issue if you are okay with the additional cost." + } } } diff --git a/tests/components/here_travel_time/test_init.py b/tests/components/here_travel_time/test_init.py index 4dbddd466334..1c949bbb2b9c 100644 --- a/tests/components/here_travel_time/test_init.py +++ b/tests/components/here_travel_time/test_init.py @@ -18,6 +18,7 @@ from homeassistant.components.here_travel_time.const import ( ) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from .const import DEFAULT_CONFIG @@ -80,3 +81,29 @@ async def test_migrate_entry_v1_1_v1_2( assert updated_entry.state is ConfigEntryState.LOADED assert updated_entry.minor_version == 2 assert updated_entry.options[CONF_TRAFFIC_MODE] is True + + +@pytest.mark.usefixtures("valid_response") +async def test_issue_multiple_here_integrations_detected( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test that an issue is created when multiple HERE integrations are detected.""" + entry1 = MockConfigEntry( + domain=DOMAIN, + unique_id="1234567890", + data=DEFAULT_CONFIG, + options=DEFAULT_OPTIONS, + ) + entry2 = MockConfigEntry( + domain=DOMAIN, + unique_id="0987654321", + data=DEFAULT_CONFIG, + options=DEFAULT_OPTIONS, + ) + entry1.add_to_hass(hass) + await hass.config_entries.async_setup(entry1.entry_id) + entry2.add_to_hass(hass) + await hass.config_entries.async_setup(entry2.entry_id) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 From c867026bdd212be2879b0a6c45a14ca12c843978 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 23 Sep 2025 16:03:42 +0200 Subject: [PATCH 064/189] Add test to validate multiple host/port for modbus. (#152658) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- tests/components/modbus/test_init.py | 139 ++++++++++++++++++++++++++- 1 file changed, 136 insertions(+), 3 deletions(-) diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 00730bd22518..aa0ef1dcca7f 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -107,6 +107,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -696,7 +697,7 @@ async def test_no_duplicate_names(hass: HomeAssistant, do_config) -> None: }, { CONF_TYPE: TCP, - CONF_HOST: TEST_MODBUS_HOST, + CONF_HOST: TEST_MODBUS_HOST + "_1", CONF_PORT: TEST_PORT_TCP, CONF_NAME: f"{TEST_MODBUS_NAME} 2", CONF_SENSORS: [ @@ -723,6 +724,32 @@ async def test_no_duplicate_names(hass: HomeAssistant, do_config) -> None: ], }, ], + [ + { + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_NAME: TEST_MODBUS_NAME, + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 9999, + } + ], + }, + { + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP + 10, + CONF_NAME: f"{TEST_MODBUS_NAME} 2", + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 9999, + } + ], + }, + ], { # Special test for scan_interval validator with scan_interval: 0 CONF_TYPE: TCP, @@ -753,10 +780,116 @@ async def test_no_duplicate_names(hass: HomeAssistant, do_config) -> None: }, ], ) -async def test_config_modbus( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus_with_pymodbus +async def test_config_modbus(hass: HomeAssistant, mock_modbus_with_pymodbus) -> None: + """Run configuration test for modbus.""" + assert len(hass.data[DOMAIN]) + + +@pytest.mark.parametrize( + "do_config", + [ + [ + # Duplicate CONF_NAME + { + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_NAME: TEST_MODBUS_NAME, + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 9999, + } + ], + }, + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: SERIAL, + CONF_BAUDRATE: 9600, + CONF_BYTESIZE: 8, + CONF_METHOD: "rtu", + CONF_PORT: TEST_PORT_SERIAL, + CONF_PARITY: "E", + CONF_STOPBITS: 1, + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 9999, + } + ], + }, + ], + [ + # Duplicate CONF_HOST+CONF_PORT (for type != SERIAL) + { + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_NAME: TEST_MODBUS_NAME, + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 9999, + } + ], + }, + { + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_NAME: TEST_MODBUS_NAME + "_1", + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 9999, + } + ], + }, + ], + [ + # Duplicate CONF_PORT (for type == SERIAL) + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: SERIAL, + CONF_BAUDRATE: 9600, + CONF_BYTESIZE: 8, + CONF_METHOD: "rtu", + CONF_PORT: TEST_PORT_SERIAL, + CONF_PARITY: "E", + CONF_STOPBITS: 1, + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 9999, + } + ], + }, + { + CONF_NAME: TEST_MODBUS_NAME + "_1", + CONF_TYPE: SERIAL, + CONF_BAUDRATE: 9600, + CONF_BYTESIZE: 8, + CONF_METHOD: "rtu", + CONF_PORT: TEST_PORT_SERIAL, + CONF_PARITY: "E", + CONF_STOPBITS: 1, + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 9999, + } + ], + }, + ], + ], +) +async def test_config_wrong_modbus( + hass: HomeAssistant, mock_modbus_with_pymodbus, issue_registry: ir.IssueRegistry ) -> None: """Run configuration test for modbus.""" + assert len(hass.data[DOMAIN]) == 1 + assert len(issue_registry.issues) == 1 + assert (DOMAIN, "duplicate_modbus_entry") in issue_registry.issues VALUE = "value" From f6b8aa893bc9f7346e35cba753ad89ebc43075e9 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 23 Sep 2025 16:31:40 +0200 Subject: [PATCH 065/189] Add mqtt image subentry support (#151586) --- homeassistant/components/mqtt/config_flow.py | 91 ++++++++++++++++++++ homeassistant/components/mqtt/const.py | 5 ++ homeassistant/components/mqtt/image.py | 17 ++-- homeassistant/components/mqtt/strings.json | 19 ++++ tests/components/mqtt/common.py | 29 +++++++ tests/components/mqtt/test_config_flow.py | 41 +++++++++ 6 files changed, 194 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 366f989b2925..26b6cd7cd457 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -39,6 +39,7 @@ from homeassistant.components.climate import ( from homeassistant.components.cover import CoverDeviceClass from homeassistant.components.file_upload import process_uploaded_file from homeassistant.components.hassio import AddonError, AddonManager, AddonState +from homeassistant.components.image import DEFAULT_CONTENT_TYPE from homeassistant.components.light import ( DEFAULT_MAX_KELVIN, DEFAULT_MIN_KELVIN, @@ -167,6 +168,7 @@ from .const import ( CONF_COMMAND_ON_TEMPLATE, CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, + CONF_CONTENT_TYPE, CONF_CURRENT_HUMIDITY_TEMPLATE, CONF_CURRENT_HUMIDITY_TOPIC, CONF_CURRENT_TEMP_TEMPLATE, @@ -205,6 +207,8 @@ from .const import ( CONF_HUMIDITY_MIN, CONF_HUMIDITY_STATE_TEMPLATE, CONF_HUMIDITY_STATE_TOPIC, + CONF_IMAGE_ENCODING, + CONF_IMAGE_TOPIC, CONF_KEEPALIVE, CONF_LAST_RESET_VALUE_TEMPLATE, CONF_MAX_KELVIN, @@ -330,6 +334,8 @@ from .const import ( CONF_TLS_INSECURE, CONF_TRANSITION, CONF_TRANSPORT, + CONF_URL_TEMPLATE, + CONF_URL_TOPIC, CONF_WHITE_COMMAND_TOPIC, CONF_WHITE_SCALE, CONF_WILL_MESSAGE, @@ -434,6 +440,7 @@ SUBENTRY_PLATFORMS = [ Platform.CLIMATE, Platform.COVER, Platform.FAN, + Platform.IMAGE, Platform.LIGHT, Platform.LOCK, Platform.NOTIFY, @@ -620,6 +627,43 @@ HUMIDITY_SELECTOR = vol.All( ), vol.Coerce(int), ) +IMAGE_CONTENT_TYPE_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + value="image/jpeg", label="Joint Photographic Expert Group image (JPEG)" + ), + SelectOptionDict( + value="image/png", label="Portable Network Graphics (PNG)" + ), + SelectOptionDict( + value="image/apng", label="Animated Portable Network Graphics (APNG)" + ), + SelectOptionDict(value="image/avif", label="AV1 Image File Format (AVIF)"), + SelectOptionDict( + value="image/gif", label="Graphics Interchange Format (GIF)" + ), + SelectOptionDict( + value="image/svg+xml", label="Scalable Vector Graphics (SVG)" + ), + SelectOptionDict(value="image/webp", label="Web Picture format (WEBP)"), + ], + mode=SelectSelectorMode.DROPDOWN, + ) +) +IMAGE_ENCODING_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=["raw", "b64"], + translation_key="image_encoding", + mode=SelectSelectorMode.DROPDOWN, + ) +) +IMAGE_PROCESSING_MODE_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=["image_url", "image_data"], + translation_key="image_processing_mode", + ) +) KELVIN_SELECTOR = NumberSelector( NumberSelectorConfig( mode=NumberSelectorMode.BOX, @@ -1019,6 +1063,7 @@ ENTITY_CONFIG_VALIDATOR: dict[ Platform.CLIMATE.value: validate_climate_platform_config, Platform.COVER.value: validate_cover_platform_config, Platform.FAN.value: validate_fan_platform_config, + Platform.IMAGE.value: None, Platform.LIGHT.value: validate_light_platform_config, Platform.LOCK.value: None, Platform.NOTIFY.value: None, @@ -1209,6 +1254,18 @@ PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = { default=lambda config: bool(config.get(CONF_DIRECTION_COMMAND_TOPIC)), ), }, + Platform.IMAGE.value: { + "image_processing_mode": PlatformField( + selector=IMAGE_PROCESSING_MODE_SELECTOR, + required=True, + exclude_from_config=True, + default=( + lambda config: "image_url" + if config.get(CONF_IMAGE_TOPIC) is None + else "image_data" + ), + ) + }, Platform.LIGHT.value: { CONF_SCHEMA: PlatformField( selector=LIGHT_SCHEMA_SELECTOR, @@ -2292,6 +2349,40 @@ PLATFORM_MQTT_FIELDS: dict[str, dict[str, PlatformField]] = { conditions=({"fan_feature_direction": True},), ), }, + Platform.IMAGE.value: { + CONF_IMAGE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({"image_processing_mode": "image_data"},), + ), + CONF_CONTENT_TYPE: PlatformField( + selector=IMAGE_CONTENT_TYPE_SELECTOR, + required=True, + default=DEFAULT_CONTENT_TYPE, + conditions=({"image_processing_mode": "image_data"},), + ), + CONF_IMAGE_ENCODING: PlatformField( + selector=IMAGE_ENCODING_SELECTOR, + required=False, + conditions=({"image_processing_mode": "image_data"},), + default="raw", + ), + CONF_URL_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({"image_processing_mode": "image_url"},), + ), + CONF_URL_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + }, Platform.LIGHT.value: { CONF_COMMAND_TOPIC: PlatformField( selector=TEXT_SELECTOR, diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 90f484b1a90e..d16617ef2a4e 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -38,9 +38,12 @@ CONF_CODE_FORMAT = "code_format" CONF_CODE_TRIGGER_REQUIRED = "code_trigger_required" CONF_COMMAND_TEMPLATE = "command_template" CONF_COMMAND_TOPIC = "command_topic" +CONF_CONTENT_TYPE = "content_type" CONF_DEFAULT_ENTITY_ID = "default_entity_id" CONF_DISCOVERY_PREFIX = "discovery_prefix" CONF_ENCODING = "encoding" +CONF_IMAGE_ENCODING = "image_encoding" +CONF_IMAGE_TOPIC = "image_topic" CONF_JSON_ATTRS_TOPIC = "json_attributes_topic" CONF_JSON_ATTRS_TEMPLATE = "json_attributes_template" CONF_KEEPALIVE = "keepalive" @@ -231,6 +234,8 @@ CONF_TILT_MIN = "tilt_min" CONF_TILT_OPEN_POSITION = "tilt_opened_value" CONF_TILT_STATE_OPTIMISTIC = "tilt_optimistic" CONF_TRANSITION = "transition" +CONF_URL_TEMPLATE = "url_template" +CONF_URL_TOPIC = "url_topic" CONF_XY_COMMAND_TEMPLATE = "xy_command_template" CONF_XY_COMMAND_TOPIC = "xy_command_topic" CONF_XY_STATE_TOPIC = "xy_state_topic" diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index a668608dd551..5e84e83bf692 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -25,6 +25,13 @@ from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_BASE_SCHEMA +from .const import ( + CONF_CONTENT_TYPE, + CONF_IMAGE_ENCODING, + CONF_IMAGE_TOPIC, + CONF_URL_TEMPLATE, + CONF_URL_TOPIC, +) from .entity import MqttEntity, async_setup_entity_entry_helper from .models import ( DATA_MQTT, @@ -39,12 +46,6 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 -CONF_CONTENT_TYPE = "content_type" -CONF_IMAGE_ENCODING = "image_encoding" -CONF_IMAGE_TOPIC = "image_topic" -CONF_URL_TEMPLATE = "url_template" -CONF_URL_TOPIC = "url_topic" - DEFAULT_NAME = "MQTT Image" GET_IMAGE_TIMEOUT = 10 @@ -67,7 +68,7 @@ PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Exclusive(CONF_URL_TOPIC, "image_topic"): valid_subscribe_topic, vol.Exclusive(CONF_IMAGE_TOPIC, "image_topic"): valid_subscribe_topic, - vol.Optional(CONF_IMAGE_ENCODING): "b64", + vol.Optional(CONF_IMAGE_ENCODING): vol.In({"b64", "raw"}), vol.Optional(CONF_URL_TEMPLATE): cv.template, } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) @@ -146,7 +147,7 @@ class MqttImage(MqttEntity, ImageEntity): def _image_data_received(self, msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" try: - if CONF_IMAGE_ENCODING in self._config: + if self._config.get(CONF_IMAGE_ENCODING) == "b64": self._last_image = b64decode(msg.payload) else: if TYPE_CHECKING: diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 3eadb2f5917a..7f14f26e8792 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -264,6 +264,7 @@ "fan_feature_preset_modes": "Preset modes support", "fan_feature_oscillation": "Oscillation support", "fan_feature_direction": "Direction support", + "image_processing_mode": "Image processing mode", "options": "Add option", "schema": "Schema", "state_class": "State class", @@ -290,6 +291,7 @@ "fan_feature_preset_modes": "The fan supports preset modes.", "fan_feature_oscillation": "The fan supports oscillation.", "fan_feature_direction": "The fan supports direction.", + "image_processing_mode": "Select how the image data is received.", "options": "Options for allowed sensor state values. The sensor’s Device class must be set to Enumeration. The 'Options' setting cannot be used together with State class or Unit of measurement.", "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)", @@ -326,8 +328,11 @@ "command_topic": "Command topic", "command_off_template": "Command \"off\" template", "command_on_template": "Command \"on\" template", + "content_type": "Content type", "force_update": "Force update", "green_template": "Green template", + "image_encoding": "Image encoding", + "image_topic": "Image topic", "last_reset_value_template": "Last reset value template", "modes": "Supported operation modes", "mode_command_topic": "Operation mode command topic", @@ -348,6 +353,8 @@ "state_topic": "State topic", "state_value_template": "State value template", "supported_color_modes": "Supported color modes", + "url_template": "URL template", + "url_topic": "URL topic", "value_template": "Value template" }, "data_description": { @@ -363,8 +370,11 @@ "command_on_template": "The [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) for \"on\" state changes. Available variables: `state`, `brightness`, `color_temp`, `red`, `green`, `blue`, `hue`, `sat`, `flash`, `transition` and `effect`. Values `red`, `green`, `blue` and `brightness` are provided as integers from range 0-255. Value of `hue` is provided as float from range 0-360. Value of `sat` is provided as float from range 0-100. Value of `color_temp` is provided as integer representing Kelvin units.", "command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to render the payload to be published at the command topic. [Learn more.]({url}#command_template)", "command_topic": "The publishing topic that will be used to control the {platform} entity. [Learn more.]({url}#command_topic)", + "content_type": "The content type or the image data that is received at the image topic.", "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.", + "image_encoding": "Select the encoding of the received image data", + "image_topic": "The MQTT topic subscribed to receive messages containing the image data. [Learn more.]({url}#image_topic)", "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)", @@ -384,6 +394,8 @@ "state_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract state from the state payload value.", "state_topic": "The MQTT topic subscribed to receive {platform} state values. [Learn more.]({url}#state_topic)", "supported_color_modes": "A list of color modes supported by the light. Possible color modes are On/Off, Brightness, Color temperature, HS, XY, RGB, RGBW, RGBWW, White. Note that if On/Off or Brightness are used, that must be the only value in the list. [Learn more.]({url}#supported_color_modes)", + "url_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract an URL from the received URL topic payload value. [Learn more.]({url}#url_template)", + "url_topic": "The MQTT topic subscribed to receive messages containing the image URL. [Learn more.]({url}#url_topic)", "value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the {platform} entity value. [Learn more.]({url}#value_template)" }, "sections": { @@ -1261,6 +1273,12 @@ "diagnostic": "Diagnostic" } }, + "image_encoding": { + "options": { + "raw": "Raw data", + "b64": "Base64 encoding" + } + }, "image_processing_mode": { "options": { "image_data": "Image data is received", @@ -1289,6 +1307,7 @@ "climate": "[%key:component::climate::title%]", "cover": "[%key:component::cover::title%]", "fan": "[%key:component::fan::title%]", + "image": "[%key:component::image::title%]", "light": "[%key:component::light::title%]", "lock": "[%key:component::lock::title%]", "notify": "[%key:component::notify::title%]", diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index 9c05fee8fd9d..af488fa613a9 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -356,6 +356,27 @@ MOCK_SUBENTRY_FAN_COMPONENT = { "speed_range_min": 1, }, } +MOCK_SUBENTRY_IMAGE_COMPONENT_DATA = { + "24402bcbd5b64a54bc32695a5ef752bf": { + "platform": "image", + "name": "Merchandise", + "entity_category": None, + "image_topic": "test-topic", + "content_type": "image/jpeg", + "image_encoding": "b64", + "entity_picture": "https://example.com/24402bcbd5b64a54bc32695a5ef752bf", + }, +} +MOCK_SUBENTRY_IMAGE_COMPONENT_URL = { + "326104eb58af48c9ab1f887cded499bb": { + "platform": "image", + "name": "Merchandise", + "entity_category": None, + "url_topic": "test-topic", + "url_template": "{{ value_json.value }}", + "entity_picture": "https://example.com/326104eb58af48c9ab1f887cded499bb", + }, +} MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT = { "8131babc5e8d4f44b82e0761d39091a2": { "platform": "light", @@ -553,6 +574,14 @@ MOCK_FAN_SUBENTRY_DATA_SINGLE = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_FAN_COMPONENT, } +MOCK_IMAGE_SUBENTRY_DATA_IMAGE_DATA = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_IMAGE_COMPONENT_DATA, +} +MOCK_IMAGE_SUBENTRY_DATA_IMAGE_URL = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_IMAGE_COMPONENT_URL, +} MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT, diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index c56e0478c217..b361b0b595b1 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -43,6 +43,8 @@ from .common import ( MOCK_CLIMATE_SUBENTRY_DATA_SINGLE, MOCK_COVER_SUBENTRY_DATA_SINGLE, MOCK_FAN_SUBENTRY_DATA_SINGLE, + MOCK_IMAGE_SUBENTRY_DATA_IMAGE_DATA, + MOCK_IMAGE_SUBENTRY_DATA_IMAGE_URL, MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE, MOCK_LOCK_SUBENTRY_DATA_SINGLE, MOCK_NOTIFY_SUBENTRY_DATA_MULTI, @@ -3279,6 +3281,45 @@ async def test_migrate_of_incompatible_config_entry( "Milk notifier Breezer", id="fan", ), + pytest.param( + MOCK_IMAGE_SUBENTRY_DATA_IMAGE_DATA, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Merchandise"}, + {"image_processing_mode": "image_data"}, + (), + { + "image_topic": "test-topic", + "content_type": "image/jpeg", + "image_encoding": "b64", + }, + ( + ( + {"image_topic": "test-topic#invalid", "content_type": "image/jpeg"}, + {"image_topic": "invalid_subscribe_topic"}, + ), + ), + "Milk notifier Merchandise", + id="notify_image_data", + ), + pytest.param( + MOCK_IMAGE_SUBENTRY_DATA_IMAGE_URL, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Merchandise"}, + {"image_processing_mode": "image_url"}, + (), + { + "url_topic": "test-topic", + "url_template": "{{ value_json.value }}", + }, + ( + ( + {"url_topic": "test-topic#invalid"}, + {"url_topic": "invalid_subscribe_topic"}, + ), + ), + "Milk notifier Merchandise", + id="notify_image_url", + ), pytest.param( MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE, {"name": "Milk notifier", "mqtt_settings": {"qos": 1}}, From 8be79ecdb03138f8f38fb4637454aca8e5d83384 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 23 Sep 2025 17:01:46 +0200 Subject: [PATCH 066/189] Move conversation trigger registration to manager (#152749) --- .../components/conversation/agent_manager.py | 26 ++++++- .../components/conversation/default_agent.py | 70 ++++++------------- homeassistant/components/conversation/http.py | 7 +- .../components/conversation/trigger.py | 20 +++++- .../conversation/test_default_agent.py | 17 +++-- 5 files changed, 79 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py index 7cd70bb768f9..bef6d933abec 100644 --- a/homeassistant/components/conversation/agent_manager.py +++ b/homeassistant/components/conversation/agent_manager.py @@ -8,7 +8,13 @@ from typing import TYPE_CHECKING, Any import voluptuous as vol -from homeassistant.core import Context, HomeAssistant, async_get_hass, callback +from homeassistant.core import ( + CALLBACK_TYPE, + Context, + HomeAssistant, + async_get_hass, + callback, +) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, intent, singleton @@ -30,6 +36,7 @@ _LOGGER = logging.getLogger(__name__) if TYPE_CHECKING: from .default_agent import DefaultAgent + from .trigger import TriggerDetails @singleton.singleton("conversation_agent") @@ -140,6 +147,7 @@ class AgentManager: self.hass = hass self._agents: dict[str, AbstractConversationAgent] = {} self.default_agent: DefaultAgent | None = None + self.triggers_details: list[TriggerDetails] = [] @callback def async_get_agent(self, agent_id: str) -> AbstractConversationAgent | None: @@ -191,4 +199,20 @@ class AgentManager: async def async_setup_default_agent(self, agent: DefaultAgent) -> None: """Set up the default agent.""" + agent.update_triggers(self.triggers_details) self.default_agent = agent + + def register_trigger(self, trigger_details: TriggerDetails) -> CALLBACK_TYPE: + """Register a trigger.""" + self.triggers_details.append(trigger_details) + if self.default_agent is not None: + self.default_agent.update_triggers(self.triggers_details) + + @callback + def unregister_trigger() -> None: + """Unregister the trigger.""" + self.triggers_details.remove(trigger_details) + if self.default_agent is not None: + self.default_agent.update_triggers(self.triggers_details) + + return unregister_trigger diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 059b378b9a83..6c238ff0c521 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -4,13 +4,11 @@ from __future__ import annotations import asyncio from collections import OrderedDict -from collections.abc import Awaitable, Callable, Iterable +from collections.abc import Callable, Iterable from dataclasses import dataclass from enum import Enum, auto -import functools import logging from pathlib import Path -import re import time from typing import IO, Any, cast @@ -53,6 +51,7 @@ from homeassistant.components.homeassistant.exposed_entities import ( async_should_expose, ) from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL +from homeassistant.core import Event, callback from homeassistant.helpers import ( area_registry as ar, device_registry as dr, @@ -74,17 +73,16 @@ from .const import DOMAIN, ConversationEntityFeature from .entity import ConversationEntity from .models import ConversationInput, ConversationResult from .trace import ConversationTraceEventType, async_conversation_trace_append +from .trigger import TriggerDetails _LOGGER = logging.getLogger(__name__) + + _DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that" _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"] _DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} -REGEX_TYPE = type(re.compile("")) -TRIGGER_CALLBACK_TYPE = Callable[ - [ConversationInput, RecognizeResult], Awaitable[str | None] -] METADATA_CUSTOM_SENTENCE = "hass_custom_sentence" METADATA_CUSTOM_FILE = "hass_custom_file" METADATA_FUZZY_MATCH = "hass_fuzzy_match" @@ -110,14 +108,6 @@ class LanguageIntents: fuzzy_responses: FuzzyLanguageResponses | None = None -@dataclass(slots=True) -class TriggerData: - """List of sentences and the callback for a trigger.""" - - sentences: list[str] - callback: TRIGGER_CALLBACK_TYPE - - @dataclass(slots=True) class SentenceTriggerResult: """Result when matching a sentence trigger in an automation.""" @@ -240,21 +230,23 @@ class DefaultAgent(ConversationEntity): """Initialize the default agent.""" self.hass = hass self._lang_intents: dict[str, LanguageIntents | object] = {} + self._load_intents_lock = asyncio.Lock() # intent -> [sentences] self._config_intents: dict[str, Any] = config_intents + + # Sentences that will trigger a callback (skipping intent recognition) + self._triggers_details: list[TriggerDetails] = [] + self._trigger_intents: Intents | None = None + + # Slot lists for entities, areas, etc. self._slot_lists: dict[str, SlotList] | None = None + self._unsub_clear_slot_list: list[Callable[[], None]] | None = None # Used to filter slot lists before intent matching self._exposed_names_trie: Trie | None = None self._unexposed_names_trie: Trie | None = None - # Sentences that will trigger a callback (skipping intent recognition) - self.trigger_sentences: list[TriggerData] = [] - self._trigger_intents: Intents | None = None - self._unsub_clear_slot_list: list[Callable[[], None]] | None = None - self._load_intents_lock = asyncio.Lock() - # LRU cache to avoid unnecessary intent matching self._intent_cache = IntentCache(capacity=128) @@ -1198,8 +1190,8 @@ class DefaultAgent(ConversationEntity): fuzzy_responses=fuzzy_responses, ) - @core.callback - def _async_clear_slot_list(self, event: core.Event[Any] | None = None) -> None: + @callback + def _async_clear_slot_list(self, event: Event[Any] | None = None) -> None: """Clear slot lists when a registry has changed.""" # Two subscribers can be scheduled at same time _LOGGER.debug("Clearing slot lists") @@ -1369,22 +1361,14 @@ class DefaultAgent(ConversationEntity): return response_template.async_render(response_args) - @core.callback - def register_trigger( - self, - sentences: list[str], - callback: TRIGGER_CALLBACK_TYPE, - ) -> core.CALLBACK_TYPE: - """Register a list of sentences that will trigger a callback when recognized.""" - trigger_data = TriggerData(sentences=sentences, callback=callback) - self.trigger_sentences.append(trigger_data) + @callback + def update_triggers(self, triggers_details: list[TriggerDetails]) -> None: + """Update triggers.""" + self._triggers_details = triggers_details # Force rebuild on next use self._trigger_intents = None - return functools.partial(self._unregister_trigger, trigger_data) - - @core.callback def _rebuild_trigger_intents(self) -> None: """Rebuild the HassIL intents object from the current trigger sentences.""" intents_dict = { @@ -1393,8 +1377,8 @@ class DefaultAgent(ConversationEntity): # Use trigger data index as a virtual intent name for HassIL. # This works because the intents are rebuilt on every # register/unregister. - str(trigger_id): {"data": [{"sentences": trigger_data.sentences}]} - for trigger_id, trigger_data in enumerate(self.trigger_sentences) + str(trigger_id): {"data": [{"sentences": trigger_details.sentences}]} + for trigger_id, trigger_details in enumerate(self._triggers_details) }, } @@ -1414,14 +1398,6 @@ class DefaultAgent(ConversationEntity): _LOGGER.debug("Rebuilt trigger intents: %s", intents_dict) - @core.callback - def _unregister_trigger(self, trigger_data: TriggerData) -> None: - """Unregister a set of trigger sentences.""" - self.trigger_sentences.remove(trigger_data) - - # Force rebuild on next use - self._trigger_intents = None - async def async_recognize_sentence_trigger( self, user_input: ConversationInput ) -> SentenceTriggerResult | None: @@ -1430,7 +1406,7 @@ class DefaultAgent(ConversationEntity): Calls the registered callbacks if there's a match and returns a sentence trigger result. """ - if not self.trigger_sentences: + if not self._triggers_details: # No triggers registered return None @@ -1475,7 +1451,7 @@ class DefaultAgent(ConversationEntity): # Gather callback responses in parallel trigger_callbacks = [ - self.trigger_sentences[trigger_id].callback(user_input, trigger_result) + self._triggers_details[trigger_id].callback(user_input, trigger_result) for trigger_id, trigger_result in result.matched_triggers.items() ] diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index ac7816daf8cb..c43e67098559 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -169,12 +169,11 @@ async def websocket_list_sentences( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: """List custom registered sentences.""" - agent = get_agent_manager(hass).default_agent - assert agent is not None + manager = get_agent_manager(hass) sentences = [] - for trigger_data in agent.trigger_sentences: - sentences.extend(trigger_data.sentences) + for trigger_details in manager.triggers_details: + sentences.extend(trigger_details.sentences) connection.send_result(msg["id"], {"trigger_sentences": sentences}) diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index b6b1273f1ab9..8f151825071c 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -2,6 +2,8 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable +from dataclasses import dataclass from typing import Any from hassil.recognize import RecognizeResult @@ -24,6 +26,18 @@ from .agent_manager import get_agent_manager from .const import DOMAIN from .models import ConversationInput +TRIGGER_CALLBACK_TYPE = Callable[ + [ConversationInput, RecognizeResult], Awaitable[str | None] +] + + +@dataclass(slots=True) +class TriggerDetails: + """List of sentences and the callback for a trigger.""" + + sentences: list[str] + callback: TRIGGER_CALLBACK_TYPE + def has_no_punctuation(value: list[str]) -> list[str]: """Validate result does not contain punctuation.""" @@ -134,6 +148,6 @@ async def async_attach_trigger( # two trigger copies for who will provide a response. return None - agent = get_agent_manager(hass).default_agent - assert agent is not None - return agent.register_trigger(sentences, call_action) + return get_agent_manager(hass).register_trigger( + TriggerDetails(sentences=sentences, callback=call_action) + ) diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 69fbe3caf820..2db9dd9fc36e 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -12,9 +12,14 @@ from syrupy.assertion import SnapshotAssertion import yaml from homeassistant.components import conversation, cover, media_player, weather -from homeassistant.components.conversation import async_get_agent, default_agent +from homeassistant.components.conversation import ( + async_get_agent, + default_agent, + get_agent_manager, +) from homeassistant.components.conversation.default_agent import METADATA_CUSTOM_SENTENCE from homeassistant.components.conversation.models import ConversationInput +from homeassistant.components.conversation.trigger import TriggerDetails from homeassistant.components.cover import SERVICE_OPEN_COVER from homeassistant.components.homeassistant.exposed_entities import ( async_get_assistant_settings, @@ -415,10 +420,10 @@ async def test_trigger_sentences(hass: HomeAssistant) -> None: trigger_sentences = ["It's party time", "It is time to party"] trigger_response = "Cowabunga!" - agent = async_get_agent(hass) + manager = get_agent_manager(hass) callback = AsyncMock(return_value=trigger_response) - unregister = agent.register_trigger(trigger_sentences, callback) + unregister = manager.register_trigger(TriggerDetails(trigger_sentences, callback)) result = await conversation.async_converse(hass, "Not the trigger", None, Context()) assert result.response.response_type == intent.IntentResponseType.ERROR @@ -461,7 +466,7 @@ async def test_trigger_sentence_response_translation( """Test translation of default response 'done'.""" hass.config.language = language - agent = async_get_agent(hass) + manager = get_agent_manager(hass) translations = { "en": {"component.conversation.conversation.agent.done": "English done"}, @@ -473,8 +478,8 @@ async def test_trigger_sentence_response_translation( "homeassistant.components.conversation.default_agent.translation.async_get_translations", return_value=translations.get(language), ): - unregister = agent.register_trigger( - ["test sentence"], AsyncMock(return_value=None) + unregister = manager.register_trigger( + TriggerDetails(["test sentence"], AsyncMock(return_value=None)) ) result = await conversation.async_converse( hass, "test sentence", None, Context() From b1ae9c95c9b04a4e204a6662019b38aba40ef1e7 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Tue, 23 Sep 2025 12:27:35 -0300 Subject: [PATCH 067/189] Add a switch entity for add-ons (#151431) Co-authored-by: Stefan Agner --- homeassistant/components/hassio/__init__.py | 3 +- .../components/hassio/coordinator.py | 13 + homeassistant/components/hassio/switch.py | 90 +++++ tests/components/hassio/test_switch.py | 320 ++++++++++++++++++ 4 files changed, 425 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/hassio/switch.py create mode 100644 tests/components/hassio/test_switch.py diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 0c15a6874214..e352f8d0cb36 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -73,6 +73,7 @@ from . import ( # noqa: F401 config_flow, diagnostics, sensor, + switch, system_health, update, ) @@ -149,7 +150,7 @@ _DEPRECATED_HassioServiceInfo = DeprecatedConstant( # If new platforms are added, be sure to import them above # so we do not make other components that depend on hassio # wait for the import of the platforms -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.UPDATE] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE] CONF_FRONTEND_REPO = "development_repo" diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index 5532c66d1ae3..2a41bbc2bdaf 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections import defaultdict +from copy import deepcopy import logging from typing import TYPE_CHECKING, Any @@ -545,3 +546,15 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): await super()._async_refresh( log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error ) + + async def force_addon_info_data_refresh(self, addon_slug: str) -> None: + """Force refresh of addon info data for a specific addon.""" + try: + slug, info = await self._update_addon_info(addon_slug) + if info is not None and DATA_KEY_ADDONS in self.data: + if slug in self.data[DATA_KEY_ADDONS]: + data = deepcopy(self.data) + data[DATA_KEY_ADDONS][slug].update(info) + self.async_set_updated_data(data) + except SupervisorError as err: + _LOGGER.warning("Could not refresh info for %s: %s", addon_slug, err) diff --git a/homeassistant/components/hassio/switch.py b/homeassistant/components/hassio/switch.py new file mode 100644 index 000000000000..43fde5190e79 --- /dev/null +++ b/homeassistant/components/hassio/switch.py @@ -0,0 +1,90 @@ +"""Switch platform for Hass.io addons.""" + +from __future__ import annotations + +import logging +from typing import Any + +from aiohasupervisor import SupervisorError + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ICON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ADDONS_COORDINATOR, ATTR_STARTED, ATTR_STATE, DATA_KEY_ADDONS +from .entity import HassioAddonEntity +from .handler import get_supervisor_client + +_LOGGER = logging.getLogger(__name__) + + +ENTITY_DESCRIPTION = SwitchEntityDescription( + key=ATTR_STATE, + name=None, + icon="mdi:puzzle", + entity_registry_enabled_default=False, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Switch set up for Hass.io config entry.""" + coordinator = hass.data[ADDONS_COORDINATOR] + + async_add_entities( + HassioAddonSwitch( + addon=addon, + coordinator=coordinator, + entity_description=ENTITY_DESCRIPTION, + ) + for addon in coordinator.data[DATA_KEY_ADDONS].values() + ) + + +class HassioAddonSwitch(HassioAddonEntity, SwitchEntity): + """Switch for Hass.io add-ons.""" + + @property + def is_on(self) -> bool | None: + """Return true if the add-on is on.""" + addon_data = self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {}) + state = addon_data.get(self.entity_description.key) + return state == ATTR_STARTED + + @property + def entity_picture(self) -> str | None: + """Return the icon of the add-on if any.""" + if not self.available: + return None + addon_data = self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {}) + if addon_data.get(ATTR_ICON): + return f"/api/hassio/addons/{self._addon_slug}/icon" + return None + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + supervisor_client = get_supervisor_client(self.hass) + try: + await supervisor_client.addons.start_addon(self._addon_slug) + except SupervisorError as err: + _LOGGER.error("Failed to start addon %s: %s", self._addon_slug, err) + raise HomeAssistantError(err) from err + + await self.coordinator.force_addon_info_data_refresh(self._addon_slug) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + supervisor_client = get_supervisor_client(self.hass) + try: + await supervisor_client.addons.stop_addon(self._addon_slug) + except SupervisorError as err: + _LOGGER.error("Failed to stop addon %s: %s", self._addon_slug, err) + raise HomeAssistantError(err) from err + + await self.coordinator.force_addon_info_data_refresh(self._addon_slug) diff --git a/tests/components/hassio/test_switch.py b/tests/components/hassio/test_switch.py new file mode 100644 index 000000000000..744a277412f7 --- /dev/null +++ b/tests/components/hassio/test_switch.py @@ -0,0 +1,320 @@ +"""The tests for the hassio switch.""" + +import os +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.hassio import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from .common import MOCK_REPOSITORIES, MOCK_STORE_ADDONS + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + +MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} + + +@pytest.fixture(autouse=True) +def mock_all( + aioclient_mock: AiohttpClientMocker, + addon_installed: AsyncMock, + store_info: AsyncMock, + addon_changelog: AsyncMock, + addon_stats: AsyncMock, + resolution_info: AsyncMock, +) -> None: + """Mock all setup requests.""" + aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) + aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"}) + aioclient_mock.get( + "http://127.0.0.1/info", + json={ + "result": "ok", + "data": { + "supervisor": "222", + "homeassistant": "0.110.0", + "hassos": "1.2.3", + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/host/info", + json={ + "result": "ok", + "data": { + "result": "ok", + "data": { + "chassis": "vm", + "operating_system": "Debian GNU/Linux 10 (buster)", + "kernel": "4.19.0-6-amd64", + }, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/core/info", + json={"result": "ok", "data": {"version_latest": "1.0.0", "version": "1.0.0"}}, + ) + aioclient_mock.get( + "http://127.0.0.1/os/info", + json={ + "result": "ok", + "data": { + "version_latest": "1.0.0", + "version": "1.0.0", + "update_available": False, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/info", + json={ + "result": "ok", + "data": { + "result": "ok", + "version": "1.0.0", + "version_latest": "1.0.0", + "auto_update": True, + "addons": [ + { + "name": "test", + "state": "started", + "slug": "test", + "installed": True, + "update_available": True, + "icon": False, + "version": "2.0.0", + "version_latest": "2.0.1", + "repository": "core", + "url": "https://github.com/home-assistant/addons/test", + }, + { + "name": "test-two", + "state": "stopped", + "slug": "test-two", + "installed": True, + "update_available": False, + "icon": True, + "version": "3.1.0", + "version_latest": "3.1.0", + "repository": "core", + "url": "https://github.com", + }, + ], + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/core/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} + ) + aioclient_mock.get( + "http://127.0.0.1/network/info", + json={ + "result": "ok", + "data": { + "host_internet": True, + "supervisor_internet": True, + }, + }, + ) + + +@pytest.mark.parametrize( + ("store_addons", "store_repositories"), [(MOCK_STORE_ADDONS, MOCK_REPOSITORIES)] +) +@pytest.mark.parametrize( + ("entity_id", "expected", "addon_state"), + [ + ("switch.test", "on", "started"), + ("switch.test_two", "off", "stopped"), + ], +) +async def test_switch_state( + hass: HomeAssistant, + entity_id: str, + expected: str, + addon_state: str, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, + addon_installed: AsyncMock, +) -> None: + """Test hassio addon switch state.""" + addon_installed.return_value.state = addon_state + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + # Verify that the entity is disabled by default. + assert hass.states.get(entity_id) is None + + # Enable the entity. + entity_registry.async_update_entity(entity_id, disabled_by=None) + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify that the entity have the expected state. + state = hass.states.get(entity_id) + assert state is not None + assert state.state == expected + + +@pytest.mark.parametrize( + ("store_addons", "store_repositories"), [(MOCK_STORE_ADDONS, MOCK_REPOSITORIES)] +) +async def test_switch_turn_on( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, + addon_installed: AsyncMock, +) -> None: + """Test turning on addon switch.""" + entity_id = "switch.test_two" + addon_installed.return_value.state = "stopped" + + # Mock the start addon API call + aioclient_mock.post("http://127.0.0.1/addons/test-two/start", json={"result": "ok"}) + + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + # Verify that the entity is disabled by default. + assert hass.states.get(entity_id) is None + + # Enable the entity. + entity_registry.async_update_entity(entity_id, disabled_by=None) + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify initial state is off + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "off" + + # Turn on the switch + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": entity_id}, + blocking=True, + ) + + # Verify the API was called + assert len(aioclient_mock.mock_calls) > 0 + start_call_found = False + for call in aioclient_mock.mock_calls: + if call[1].path == "/addons/test-two/start" and call[0] == "POST": + start_call_found = True + break + assert start_call_found + + +@pytest.mark.parametrize( + ("store_addons", "store_repositories"), [(MOCK_STORE_ADDONS, MOCK_REPOSITORIES)] +) +async def test_switch_turn_off( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, + addon_installed: AsyncMock, +) -> None: + """Test turning off addon switch.""" + entity_id = "switch.test" + addon_installed.return_value.state = "started" + + # Mock the stop addon API call + aioclient_mock.post("http://127.0.0.1/addons/test/stop", json={"result": "ok"}) + + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + # Verify that the entity is disabled by default. + assert hass.states.get(entity_id) is None + + # Enable the entity. + entity_registry.async_update_entity(entity_id, disabled_by=None) + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify initial state is on + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "on" + + # Turn off the switch + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": entity_id}, + blocking=True, + ) + + # Verify the API was called + assert len(aioclient_mock.mock_calls) > 0 + stop_call_found = False + for call in aioclient_mock.mock_calls: + if call[1].path == "/addons/test/stop" and call[0] == "POST": + stop_call_found = True + break + assert stop_call_found From 3f70084d7ffe356f4450235a7bfe1743c01b3408 Mon Sep 17 00:00:00 2001 From: Rohan Kapoor Date: Tue, 23 Sep 2025 10:15:52 -0700 Subject: [PATCH 068/189] Handle ignored and disabled entries correctly in zeroconf discovery for Music Assistant (#152792) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- .../components/music_assistant/config_flow.py | 6 ++- .../music_assistant/test_config_flow.py | 40 ++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/music_assistant/config_flow.py b/homeassistant/components/music_assistant/config_flow.py index 09931040d6a9..3426a08852a9 100644 --- a/homeassistant/components/music_assistant/config_flow.py +++ b/homeassistant/components/music_assistant/config_flow.py @@ -13,7 +13,7 @@ from music_assistant_client.exceptions import ( from music_assistant_models.api import ServerInfoMessage import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_IGNORE, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client @@ -113,6 +113,10 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN): ) if existing_entry: + # If the entry was ignored or disabled, don't make any changes + if existing_entry.source == SOURCE_IGNORE or existing_entry.disabled_by: + return self.async_abort(reason="already_configured") + # Test connectivity to the current URL first current_url = existing_entry.data[CONF_URL] try: diff --git a/tests/components/music_assistant/test_config_flow.py b/tests/components/music_assistant/test_config_flow.py index 57eafd72ecf9..c9cb465b7c71 100644 --- a/tests/components/music_assistant/test_config_flow.py +++ b/tests/components/music_assistant/test_config_flow.py @@ -15,7 +15,7 @@ import pytest from homeassistant.components.music_assistant.config_flow import CONF_URL from homeassistant.components.music_assistant.const import DEFAULT_NAME, DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -362,3 +362,41 @@ async def test_zeroconf_existing_entry_broken_url( # Verify the URL was updated in the config entry updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) assert updated_entry.data[CONF_URL] == "http://discovered-working-url:8095" + + +async def test_zeroconf_existing_entry_ignored( + hass: HomeAssistant, + mock_get_server_info: AsyncMock, +) -> None: + """Test zeroconf flow when existing entry was ignored.""" + # Create an ignored config entry (no URL field) + ignored_config_entry = MockConfigEntry( + domain=DOMAIN, + title="Music Assistant", + data={}, # No URL field for ignored entries + unique_id="1234", + source=SOURCE_IGNORE, + ) + ignored_config_entry.add_to_hass(hass) + + # Mock server info with discovered URL + server_info = ServerInfoMessage.from_json( + await async_load_fixture(hass, "server_info_message.json", DOMAIN) + ) + server_info.base_url = "http://discovered-url:8095" + mock_get_server_info.return_value = server_info + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DATA, + ) + await hass.async_block_till_done() + + # Should abort because entry was ignored (respect user's choice) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + # Verify the ignored entry was not modified + ignored_entry = hass.config_entries.async_get_entry(ignored_config_entry.entry_id) + assert ignored_entry.data == {} # Still no URL field + assert ignored_entry.source == SOURCE_IGNORE From 29a42a8e58ef2e2431ff9d9ef3816e0fe17e9d9d Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 23 Sep 2025 19:52:58 +0200 Subject: [PATCH 069/189] Add analytics platform to automation (#152828) --- .../components/analytics/__init__.py | 2 + .../components/automation/analytics.py | 24 +++++++++++ tests/components/automation/test_analytics.py | 41 +++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 homeassistant/components/automation/analytics.py create mode 100644 tests/components/automation/test_analytics.py diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index 4e805814632c..230d172ca91a 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -18,6 +18,7 @@ from .analytics import ( AnalyticsModifications, DeviceAnalyticsModifications, EntityAnalyticsModifications, + async_devices_payload, ) from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA from .http import AnalyticsDevicesView @@ -27,6 +28,7 @@ __all__ = [ "AnalyticsModifications", "DeviceAnalyticsModifications", "EntityAnalyticsModifications", + "async_devices_payload", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/automation/analytics.py b/homeassistant/components/automation/analytics.py new file mode 100644 index 000000000000..06c9a553d8ae --- /dev/null +++ b/homeassistant/components/automation/analytics.py @@ -0,0 +1,24 @@ +"""Analytics platform.""" + +from homeassistant.components.analytics import ( + AnalyticsInput, + AnalyticsModifications, + EntityAnalyticsModifications, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + + +async def async_modify_analytics( + hass: HomeAssistant, analytics_input: AnalyticsInput +) -> AnalyticsModifications: + """Modify the analytics.""" + ent_reg = er.async_get(hass) + + entities: dict[str, EntityAnalyticsModifications] = {} + for entity_id in analytics_input.entity_ids: + entity_entry = ent_reg.entities[entity_id] + if entity_entry.capabilities is not None: + entities[entity_id] = EntityAnalyticsModifications(capabilities=None) + + return AnalyticsModifications(entities=entities) diff --git a/tests/components/automation/test_analytics.py b/tests/components/automation/test_analytics.py new file mode 100644 index 000000000000..803103d0245c --- /dev/null +++ b/tests/components/automation/test_analytics.py @@ -0,0 +1,41 @@ +"""Tests for analytics platform.""" + +import pytest + +from homeassistant.components.analytics import async_devices_payload +from homeassistant.components.automation import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + + +@pytest.mark.asyncio +async def test_analytics( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test the analytics platform.""" + await async_setup_component(hass, "analytics", {}) + + entity_registry.async_get_or_create( + domain="automation", + platform="automation", + unique_id="automation1", + suggested_object_id="automation1", + capabilities={"id": "automation1"}, + ) + + result = await async_devices_payload(hass) + assert result["integrations"][DOMAIN]["entities"] == [ + { + "assumed_state": None, + "capabilities": None, + "domain": "automation", + "entity_category": None, + "has_entity_name": False, + "modified_by_integration": [ + "capabilities", + ], + "original_device_class": None, + "unit_of_measurement": None, + }, + ] From 014881d9850b2ebb6629a3a8579c75e14bfa597a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 23 Sep 2025 19:53:12 +0200 Subject: [PATCH 070/189] Fix error handling in subscription info retrieval and update tests (#148397) Co-authored-by: Joost Lekkerkerker --- .../components/cloud/subscription.py | 6 +++- tests/components/cloud/test_subscription.py | 29 +++++++++++++++---- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/cloud/subscription.py b/homeassistant/components/cloud/subscription.py index c1b8fc095c3d..980823243bcd 100644 --- a/homeassistant/components/cloud/subscription.py +++ b/homeassistant/components/cloud/subscription.py @@ -25,7 +25,11 @@ async def async_subscription_info(cloud: Cloud[CloudClient]) -> SubscriptionInfo return await cloud.payments.subscription_info() except PaymentsApiError as exception: _LOGGER.error("Failed to fetch subscription information - %s", exception) - + except TimeoutError: + _LOGGER.error( + "A timeout of %s was reached while trying to fetch subscription information", + REQUEST_TIMEOUT, + ) return None diff --git a/tests/components/cloud/test_subscription.py b/tests/components/cloud/test_subscription.py index ba45e6bca57d..45c199421d6d 100644 --- a/tests/components/cloud/test_subscription.py +++ b/tests/components/cloud/test_subscription.py @@ -1,6 +1,7 @@ """Test cloud subscription functions.""" -from unittest.mock import AsyncMock, Mock +import asyncio +from unittest.mock import AsyncMock, Mock, patch from hass_nabucasa import Cloud, payments_api import pytest @@ -30,19 +31,35 @@ async def mocked_cloud_object(hass: HomeAssistant) -> Cloud: ) +async def test_fetching_subscription_with_api_error( + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, + mocked_cloud: Cloud, +) -> None: + """Test that we handle API errors.""" + mocked_cloud.payments.subscription_info.side_effect = payments_api.PaymentsApiError( + "There was an error with the API" + ) + + assert await async_subscription_info(mocked_cloud) is None + assert ( + "Failed to fetch subscription information - There was an error with the API" + in caplog.text + ) + + async def test_fetching_subscription_with_timeout_error( aioclient_mock: AiohttpClientMocker, caplog: pytest.LogCaptureFixture, mocked_cloud: Cloud, ) -> None: """Test that we handle timeout error.""" - mocked_cloud.payments.subscription_info.side_effect = payments_api.PaymentsApiError( - "Timeout reached while calling API" - ) + mocked_cloud.payments.subscription_info = lambda: asyncio.sleep(1) + with patch("homeassistant.components.cloud.subscription.REQUEST_TIMEOUT", 0): + assert await async_subscription_info(mocked_cloud) is None - assert await async_subscription_info(mocked_cloud) is None assert ( - "Failed to fetch subscription information - Timeout reached while calling API" + "A timeout of 0 was reached while trying to fetch subscription information" in caplog.text ) From f00ab80d17e1c4c41d04144c377057bd6c933acf Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 23 Sep 2025 19:53:53 +0200 Subject: [PATCH 071/189] Add analytics platform to template (#152824) --- .../components/template/analytics.py | 43 +++++++ tests/components/template/test_analytics.py | 105 ++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 homeassistant/components/template/analytics.py create mode 100644 tests/components/template/test_analytics.py diff --git a/homeassistant/components/template/analytics.py b/homeassistant/components/template/analytics.py new file mode 100644 index 000000000000..e4db2c5c70a6 --- /dev/null +++ b/homeassistant/components/template/analytics.py @@ -0,0 +1,43 @@ +"""Analytics platform.""" + +from homeassistant.components.analytics import ( + AnalyticsInput, + AnalyticsModifications, + EntityAnalyticsModifications, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, split_entity_id +from homeassistant.helpers import entity_registry as er + +FILTERED_PLATFORM_CAPABILITY: dict[str, str] = { + Platform.FAN: "preset_modes", + Platform.SELECT: "options", +} + + +async def async_modify_analytics( + hass: HomeAssistant, analytics_input: AnalyticsInput +) -> AnalyticsModifications: + """Modify the analytics.""" + ent_reg = er.async_get(hass) + + entities: dict[str, EntityAnalyticsModifications] = {} + for entity_id in analytics_input.entity_ids: + platform = split_entity_id(entity_id)[0] + if platform not in FILTERED_PLATFORM_CAPABILITY: + continue + + entity_entry = ent_reg.entities[entity_id] + if entity_entry.capabilities is not None: + filtered_capability = FILTERED_PLATFORM_CAPABILITY[platform] + if filtered_capability not in entity_entry.capabilities: + continue + + capabilities = dict(entity_entry.capabilities) + capabilities[filtered_capability] = len(capabilities[filtered_capability]) + + entities[entity_id] = EntityAnalyticsModifications( + capabilities=capabilities + ) + + return AnalyticsModifications(entities=entities) diff --git a/tests/components/template/test_analytics.py b/tests/components/template/test_analytics.py new file mode 100644 index 000000000000..33a0373bd170 --- /dev/null +++ b/tests/components/template/test_analytics.py @@ -0,0 +1,105 @@ +"""Tests for analytics platform.""" + +import pytest + +from homeassistant.components.analytics import async_devices_payload +from homeassistant.components.template import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + + +@pytest.mark.asyncio +async def test_analytics( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test the analytics platform.""" + await async_setup_component(hass, "analytics", {}) + + entity_registry.async_get_or_create( + domain=Platform.FAN, + platform="template", + unique_id="fan1", + suggested_object_id="my_fan", + capabilities={"options": ["a", "b", "c"], "preset_modes": ["auto", "eco"]}, + ) + entity_registry.async_get_or_create( + domain=Platform.SELECT, + platform="template", + unique_id="select1", + suggested_object_id="my_select", + capabilities={"not_filtered": "xyz", "options": ["a", "b", "c"]}, + ) + entity_registry.async_get_or_create( + domain=Platform.SELECT, + platform="template", + unique_id="select2", + suggested_object_id="my_select", + capabilities={"not_filtered": "xyz"}, + ) + entity_registry.async_get_or_create( + domain=Platform.LIGHT, + platform="template", + unique_id="light1", + suggested_object_id="my_light", + capabilities={"not_filtered": "abc"}, + ) + + result = await async_devices_payload(hass) + assert result["integrations"][DOMAIN]["entities"] == [ + { + "assumed_state": None, + "capabilities": { + "options": ["a", "b", "c"], + "preset_modes": 2, + }, + "domain": "fan", + "entity_category": None, + "has_entity_name": False, + "modified_by_integration": [ + "capabilities", + ], + "original_device_class": None, + "unit_of_measurement": None, + }, + { + "assumed_state": None, + "capabilities": { + "not_filtered": "xyz", + "options": 3, + }, + "domain": "select", + "entity_category": None, + "has_entity_name": False, + "modified_by_integration": [ + "capabilities", + ], + "original_device_class": None, + "unit_of_measurement": None, + }, + { + "assumed_state": None, + "capabilities": { + "not_filtered": "xyz", + }, + "domain": "select", + "entity_category": None, + "has_entity_name": False, + "modified_by_integration": None, + "original_device_class": None, + "unit_of_measurement": None, + }, + { + "assumed_state": None, + "capabilities": { + "not_filtered": "abc", + }, + "domain": "light", + "entity_category": None, + "has_entity_name": False, + "modified_by_integration": None, + "original_device_class": None, + "unit_of_measurement": None, + }, + ] From a78c909b342764220861ad6bc234b187463e621a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 23 Sep 2025 20:35:47 +0200 Subject: [PATCH 072/189] Rename cover property in tuya (#152822) --- homeassistant/components/tuya/cover.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index be75ff9d6944..8b02d0adbda6 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -260,11 +260,10 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): self._motor_reverse_mode_enum = enum_type @property - def _is_motor_forward(self) -> bool: - """Check if the cover direction should be reversed based on motor_reverse_mode. - - If the motor is "forward" (=default) then the positions need to be reversed. - """ + def _is_position_reversed(self) -> bool: + """Check if the cover position and direction should be reversed.""" + # The default is True + # Having motor_reverse_mode == "back" cancels the inversion return not ( self._motor_reverse_mode_enum and self.device.status.get(self._motor_reverse_mode_enum.dpcode) == "back" @@ -281,7 +280,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): return round( self._current_position.remap_value_to( - position, 0, 100, reverse=self._is_motor_forward + position, 0, 100, reverse=self._is_position_reversed ) ) @@ -335,7 +334,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): "code": self._set_position.dpcode, "value": round( self._set_position.remap_value_from( - 100, 0, 100, reverse=self._is_motor_forward + 100, 0, 100, reverse=self._is_position_reversed ), ), } @@ -361,7 +360,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): "code": self._set_position.dpcode, "value": round( self._set_position.remap_value_from( - 0, 0, 100, reverse=self._is_motor_forward + 0, 0, 100, reverse=self._is_position_reversed ), ), } @@ -384,7 +383,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): kwargs[ATTR_POSITION], 0, 100, - reverse=self._is_motor_forward, + reverse=self._is_position_reversed, ) ), } @@ -417,7 +416,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): kwargs[ATTR_TILT_POSITION], 0, 100, - reverse=self._is_motor_forward, + reverse=self._is_position_reversed, ) ), } From 5d543d2185d70c7b342828d43e1936ce866a34d3 Mon Sep 17 00:00:00 2001 From: Sarah Seidman Date: Tue, 23 Sep 2025 14:36:06 -0400 Subject: [PATCH 073/189] Bump pydroplet version to 2.3.3 (#152832) --- homeassistant/components/droplet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/droplet/manifest.json b/homeassistant/components/droplet/manifest.json index bd5f1ba2a0bb..f4a03ebfb21a 100644 --- a/homeassistant/components/droplet/manifest.json +++ b/homeassistant/components/droplet/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/droplet", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["pydroplet==2.3.2"], + "requirements": ["pydroplet==2.3.3"], "zeroconf": ["_droplet._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e49466c857c1..ce8df02b5b57 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1954,7 +1954,7 @@ pydrawise==2025.9.0 pydroid-ipcam==3.0.0 # homeassistant.components.droplet -pydroplet==2.3.2 +pydroplet==2.3.3 # homeassistant.components.ebox pyebox==1.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 14866be0ea96..2f461dba0793 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1638,7 +1638,7 @@ pydrawise==2025.9.0 pydroid-ipcam==3.0.0 # homeassistant.components.droplet -pydroplet==2.3.2 +pydroplet==2.3.3 # homeassistant.components.ecoforest pyecoforest==0.4.0 From a2a726de34a448bf78f0d31cf2bbf2370d2c400c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 23 Sep 2025 20:54:52 +0200 Subject: [PATCH 074/189] Rename function arguments in modbus (#152814) --- homeassistant/components/modbus/light.py | 8 ++++---- homeassistant/components/modbus/modbus.py | 24 +++++++++++++---------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index 4c27ffb456b6..36b8f4415b82 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -117,7 +117,7 @@ class ModbusLight(ModbusToggleEntity, LightEntity): conv_brightness = self._convert_brightness_to_modbus(brightness) await self._hub.async_pb_call( - unit=self._device_address, + device_address=self._device_address, address=self._brightness_address, value=conv_brightness, use_call=CALL_TYPE_WRITE_REGISTER, @@ -133,7 +133,7 @@ class ModbusLight(ModbusToggleEntity, LightEntity): conv_color_temp_kelvin = self._convert_color_temp_to_modbus(color_temp_kelvin) await self._hub.async_pb_call( - unit=self._device_address, + device_address=self._device_address, address=self._color_temp_address, value=conv_color_temp_kelvin, use_call=CALL_TYPE_WRITE_REGISTER, @@ -150,7 +150,7 @@ class ModbusLight(ModbusToggleEntity, LightEntity): if self._brightness_address: brightness_result = await self._hub.async_pb_call( - unit=self._device_address, + device_address=self._device_address, value=1, address=self._brightness_address, use_call=CALL_TYPE_REGISTER_HOLDING, @@ -167,7 +167,7 @@ class ModbusLight(ModbusToggleEntity, LightEntity): if self._color_temp_address: color_result = await self._hub.async_pb_call( - unit=self._device_address, + device_address=self._device_address, value=1, address=self._color_temp_address, use_call=CALL_TYPE_REGISTER_HOLDING, diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 1f797c82a089..26992404e38f 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -370,11 +370,17 @@ class ModbusHub: _LOGGER.info(f"modbus {self.name} communication closed") async def low_level_pb_call( - self, slave: int | None, address: int, value: int | list[int], use_call: str + self, + device_address: int | None, + address: int, + value: int | list[int], + use_call: str, ) -> ModbusPDU | None: """Call sync. pymodbus.""" kwargs: dict[str, Any] = ( - {DEVICE_ID: slave} if slave is not None else {DEVICE_ID: 1} + {DEVICE_ID: device_address} + if device_address is not None + else {DEVICE_ID: 1} ) entry = self._pb_request[use_call] @@ -386,28 +392,26 @@ class ModbusHub: try: result: ModbusPDU = await entry.func(address, **kwargs) except ModbusException as exception_error: - error = f"Error: device: {slave} address: {address} -> {exception_error!s}" + error = f"Error: device: {device_address} address: {address} -> {exception_error!s}" self._log_error(error) return None if not result: - error = ( - f"Error: device: {slave} address: {address} -> pymodbus returned None" - ) + error = f"Error: device: {device_address} address: {address} -> pymodbus returned None" self._log_error(error) return None if not hasattr(result, entry.attr): - error = f"Error: device: {slave} address: {address} -> {result!s}" + error = f"Error: device: {device_address} address: {address} -> {result!s}" self._log_error(error) return None if result.isError(): - error = f"Error: device: {slave} address: {address} -> pymodbus returned isError True" + error = f"Error: device: {device_address} address: {address} -> pymodbus returned isError True" self._log_error(error) return None return result async def async_pb_call( self, - unit: int | None, + device_address: int | None, address: int, value: int | list[int], use_call: str, @@ -415,7 +419,7 @@ class ModbusHub: """Convert async to sync pymodbus call.""" if not self._client: return None - result = await self.low_level_pb_call(unit, address, value, use_call) + result = await self.low_level_pb_call(device_address, address, value, use_call) if self._msg_wait: await asyncio.sleep(self._msg_wait) return result From 2ab051b7169ca2bdb897f07e6a03e822a9b070e2 Mon Sep 17 00:00:00 2001 From: andreimoraru Date: Tue, 23 Sep 2025 22:03:53 +0300 Subject: [PATCH 075/189] Bump yt-dlp to 2025.09.23 (#152818) --- 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 beb22dd0858b..288921b624e6 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.09.05"], + "requirements": ["yt-dlp[default]==2025.09.23"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index ce8df02b5b57..83da8573a51a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3204,7 +3204,7 @@ youless-api==2.2.0 youtubeaio==2.0.0 # homeassistant.components.media_extractor -yt-dlp[default]==2025.09.05 +yt-dlp[default]==2025.09.23 # homeassistant.components.zabbix zabbix-utils==2.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2f461dba0793..698e558de6ed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2657,7 +2657,7 @@ youless-api==2.2.0 youtubeaio==2.0.0 # homeassistant.components.media_extractor -yt-dlp[default]==2025.09.05 +yt-dlp[default]==2025.09.23 # homeassistant.components.zamg zamg==0.3.6 From ca186925af33897bcfc060e63e57afc263dd2e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 23 Sep 2025 21:28:24 +0200 Subject: [PATCH 076/189] Add Matter Thermostat OutdoorTemperature sensor (#152632) --- homeassistant/components/matter/sensor.py | 19 +++++++ homeassistant/components/matter/strings.json | 3 + .../matter/fixtures/nodes/thermostat.json | 1 + .../matter/snapshots/test_sensor.ambr | 56 +++++++++++++++++++ tests/components/matter/test_sensor.py | 20 +++++++ 5 files changed, 99 insertions(+) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index f5f1fe0e73e0..b8249e9efa3a 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -152,6 +152,8 @@ PUMP_CONTROL_MODE_MAP = { clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kUnknownEnumValue: None, } +TEMPERATURE_SCALING_FACTOR = 100 + async def async_setup_entry( hass: HomeAssistant, @@ -1141,6 +1143,23 @@ DISCOVERY_SCHEMAS = [ device_type=(device_types.Thermostat,), allow_multi=True, # also used for climate entity ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ThermostatOutdoorTemperature", + translation_key="outdoor_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=1, + device_class=SensorDeviceClass.TEMPERATURE, + device_to_ha=lambda x: ( + None if x is None else x / TEMPERATURE_SCALING_FACTOR + ), + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.Thermostat.Attributes.OutdoorTemperature,), + device_type=(device_types.Thermostat, device_types.RoomAirConditioner), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterOperationalStateSensorEntityDescription( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 7dae7638d8d6..85ad6527653d 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -485,6 +485,9 @@ "apparent_current": { "name": "Apparent current" }, + "outdoor_temperature": { + "name": "Outdoor temperature" + }, "reactive_current": { "name": "Reactive current" }, diff --git a/tests/components/matter/fixtures/nodes/thermostat.json b/tests/components/matter/fixtures/nodes/thermostat.json index a7abff41331b..bb42b8926b9d 100644 --- a/tests/components/matter/fixtures/nodes/thermostat.json +++ b/tests/components/matter/fixtures/nodes/thermostat.json @@ -317,6 +317,7 @@ "1/64/65529": [], "1/64/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/513/0": 2830, + "1/513/1": 1250, "1/513/3": null, "1/513/4": null, "1/513/5": null, diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 2567ce2e936b..911ea0049952 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -6847,6 +6847,62 @@ 'state': '21.0', }) # --- +# name: test_sensors[thermostat][sensor.longan_link_hvac_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.longan_link_hvac_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': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'outdoor_temperature', + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ThermostatOutdoorTemperature-513-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[thermostat][sensor.longan_link_hvac_outdoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Longan link HVAC Outdoor temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.longan_link_hvac_outdoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.5', + }) +# --- # name: test_sensors[thermostat][sensor.longan_link_hvac_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 2254c021c6ae..2414bafc80d0 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -233,6 +233,26 @@ async def test_eve_thermo_sensor( assert state.state == "18.0" +@pytest.mark.parametrize("node_fixture", ["thermostat"]) +async def test_thermostat_outdoor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test OutdoorTemperature.""" + # OutdoorTemperature + state = hass.states.get("sensor.longan_link_hvac_outdoor_temperature") + assert state + assert state.state == "12.5" + + set_node_attribute(matter_node, 1, 513, 1, -550) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.longan_link_hvac_outdoor_temperature") + assert state + assert state.state == "-5.5" + + @pytest.mark.parametrize("node_fixture", ["pressure_sensor"]) async def test_pressure_sensor( hass: HomeAssistant, From 874ca1323bba054f309c70a07967132853ec7bd8 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 23 Sep 2025 16:00:31 -0400 Subject: [PATCH 077/189] Simplified ZHA adapter migration and setup flow (#152389) --- homeassistant/components/zha/__init__.py | 2 +- homeassistant/components/zha/api.py | 2 +- homeassistant/components/zha/config_flow.py | 317 ++++++-- homeassistant/components/zha/radio_manager.py | 163 ++-- .../repairs/network_settings_inconsistent.py | 2 +- homeassistant/components/zha/strings.json | 87 +- .../homeassistant_connect_zbt2/conftest.py | 2 +- .../homeassistant_hardware/conftest.py | 2 +- .../homeassistant_sky_connect/conftest.py | 2 +- .../homeassistant_yellow/conftest.py | 2 +- .../homeassistant_yellow/test_init.py | 20 +- tests/components/zha/test_config_flow.py | 759 ++++++++++++------ tests/components/zha/test_radio_manager.py | 50 +- 13 files changed, 996 insertions(+), 414 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index e446f32cf08d..c3406181ff8b 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -134,7 +134,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b device_registry = dr.async_get(hass) radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry) - async with radio_mgr.connect_zigpy_app() as app: + async with radio_mgr.create_zigpy_app(connect=False) as app: for dev in app.devices.values(): dev_entry = device_registry.async_get_device( identifiers={(DOMAIN, str(dev.ieee))}, diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index 60960a3e9fc9..9dbd00273b61 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -56,7 +56,7 @@ async def async_get_last_network_settings( radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry) - async with radio_mgr.connect_zigpy_app() as app: + async with radio_mgr.create_zigpy_app(connect=False) as app: try: settings = max(app.backups, key=lambda b: b.backup_time) except ValueError: diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index b98e53f98d8d..cb0b26d6ac0a 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from abc import abstractmethod import collections from contextlib import suppress import json @@ -13,6 +14,7 @@ import voluptuous as vol from zha.application.const import RadioType import zigpy.backups from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH +from zigpy.exceptions import CannotWriteNetworkSettings, DestructiveWriteNetworkSettings from homeassistant.components import onboarding, usb from homeassistant.components.file_upload import process_uploaded_file @@ -21,7 +23,6 @@ from homeassistant.components.homeassistant_hardware import silabs_multiprotocol from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware from homeassistant.config_entries import ( SOURCE_IGNORE, - SOURCE_ZEROCONF, ConfigEntry, ConfigEntryBaseFlow, ConfigEntryState, @@ -32,6 +33,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.selector import FileSelector, FileSelectorConfig @@ -40,6 +42,7 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util import dt as dt_util from .const import CONF_BAUDRATE, CONF_FLOW_CONTROL, CONF_RADIO_TYPE, DOMAIN +from .helpers import get_zha_gateway from .radio_manager import ( DEVICE_SCHEMA, HARDWARE_DISCOVERY_SCHEMA, @@ -49,12 +52,22 @@ from .radio_manager import ( ) CONF_MANUAL_PATH = "Enter Manually" -SUPPORTED_PORT_SETTINGS = ( - CONF_BAUDRATE, - CONF_FLOW_CONTROL, -) DECONZ_DOMAIN = "deconz" +# The ZHA config flow takes different branches depending on if you are migrating to a +# new adapter via discovery or setting it up from scratch + +# For the fast path, we automatically migrate everything and restore the most recent backup +MIGRATION_STRATEGY_RECOMMENDED = "migration_strategy_recommended" +MIGRATION_STRATEGY_ADVANCED = "migration_strategy_advanced" + +# Similarly, setup follows the same approach: we create a new network +SETUP_STRATEGY_RECOMMENDED = "setup_strategy_recommended" +SETUP_STRATEGY_ADVANCED = "setup_strategy_advanced" + +# For the advanced paths, we allow users to pick how to form a network: form a brand new +# network, use the settings currently on the stick, restore from a database backup, or +# restore from a JSON backup FORMATION_STRATEGY = "formation_strategy" FORMATION_FORM_NEW_NETWORK = "form_new_network" FORMATION_FORM_INITIAL_NETWORK = "form_initial_network" @@ -170,24 +183,35 @@ class BaseZhaFlow(ConfigEntryBaseFlow): self._hass = hass self._radio_mgr.hass = hass - async def _async_create_radio_entry(self) -> ConfigFlowResult: - """Create a config entry with the current flow state.""" + async def _get_config_entry_data(self) -> dict: + """Extract ZHA config entry data from the radio manager.""" assert self._radio_mgr.radio_type is not None assert self._radio_mgr.device_path is not None assert self._radio_mgr.device_settings is not None - device_settings = self._radio_mgr.device_settings.copy() - device_settings[CONF_DEVICE_PATH] = await self.hass.async_add_executor_job( - usb.get_serial_by_id, self._radio_mgr.device_path - ) + try: + device_path = await self.hass.async_add_executor_job( + usb.get_serial_by_id, self._radio_mgr.device_path + ) + except OSError as error: + raise AbortFlow( + reason="cannot_resolve_path", + description_placeholders={"path": self._radio_mgr.device_path}, + ) from error - return self.async_create_entry( - title=self._title, - data={ - CONF_DEVICE: DEVICE_SCHEMA(device_settings), - CONF_RADIO_TYPE: self._radio_mgr.radio_type.name, - }, - ) + return { + CONF_DEVICE: DEVICE_SCHEMA( + { + **self._radio_mgr.device_settings, + CONF_DEVICE_PATH: device_path, + } + ), + CONF_RADIO_TYPE: self._radio_mgr.radio_type.name, + } + + @abstractmethod + async def _async_create_radio_entry(self) -> ConfigFlowResult: + """Create a config entry with the current flow state.""" async def async_step_choose_serial_port( self, user_input: dict[str, Any] | None = None @@ -288,43 +312,44 @@ class BaseZhaFlow(ConfigEntryBaseFlow): if user_input is not None: self._title = user_input[CONF_DEVICE_PATH] self._radio_mgr.device_path = user_input[CONF_DEVICE_PATH] - self._radio_mgr.device_settings = user_input.copy() + self._radio_mgr.device_settings = DEVICE_SCHEMA( + { + CONF_DEVICE_PATH: self._radio_mgr.device_path, + CONF_BAUDRATE: user_input[CONF_BAUDRATE], + # `None` shows up as the empty string in the frontend + CONF_FLOW_CONTROL: ( + user_input[CONF_FLOW_CONTROL] + if user_input[CONF_FLOW_CONTROL] != "none" + else None + ), + } + ) if await self._radio_mgr.radio_type.controller.probe(user_input): return await self.async_step_verify_radio() errors["base"] = "cannot_connect" - schema = { - vol.Required( - CONF_DEVICE_PATH, default=self._radio_mgr.device_path or vol.UNDEFINED - ): str - } - - source = self.context.get("source") - for ( - param, - value, - ) in DEVICE_SCHEMA.schema.items(): - if param not in SUPPORTED_PORT_SETTINGS: - continue - - if source == SOURCE_ZEROCONF and param == CONF_BAUDRATE: - value = 115200 - param = vol.Required(CONF_BAUDRATE, default=value) - elif ( - self._radio_mgr.device_settings is not None - and param in self._radio_mgr.device_settings - ): - param = vol.Required( - str(param), default=self._radio_mgr.device_settings[param] - ) - - schema[param] = value + device_settings = self._radio_mgr.device_settings or {} return self.async_show_form( step_id="manual_port_config", - data_schema=vol.Schema(schema), + data_schema=vol.Schema( + { + vol.Required( + CONF_DEVICE_PATH, + default=self._radio_mgr.device_path or vol.UNDEFINED, + ): str, + vol.Required( + CONF_BAUDRATE, + default=device_settings.get(CONF_BAUDRATE) or 115200, + ): int, + vol.Required( + CONF_FLOW_CONTROL, + default=device_settings.get(CONF_FLOW_CONTROL) or "none", + ): vol.In(["hardware", "software", "none"]), + } + ), errors=errors, ) @@ -333,10 +358,15 @@ class BaseZhaFlow(ConfigEntryBaseFlow): ) -> ConfigFlowResult: """Add a warning step to dissuade the use of deprecated radios.""" assert self._radio_mgr.radio_type is not None + await self._radio_mgr.async_read_backups_from_database() # Skip this step if we are using a recommended radio if user_input is not None or self._radio_mgr.radio_type in RECOMMENDED_RADIOS: - return await self.async_step_choose_formation_strategy() + # ZHA disables the single instance check and will decide at runtime if we + # are migrating or setting up from scratch + if self.hass.config_entries.async_entries(DOMAIN): + return await self.async_step_choose_migration_strategy() + return await self.async_step_choose_setup_strategy() return self.async_show_form( step_id="verify_radio", @@ -348,6 +378,91 @@ class BaseZhaFlow(ConfigEntryBaseFlow): }, ) + async def async_step_choose_setup_strategy( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Choose how to set up the integration from scratch.""" + + # Allow onboarding for new users to just create a new network automatically + if ( + not onboarding.async_is_onboarded(self.hass) + and not self.hass.config_entries.async_entries(DOMAIN) + and not self._radio_mgr.backups + ): + return await self.async_step_setup_strategy_recommended() + + return self.async_show_menu( + step_id="choose_setup_strategy", + menu_options=[ + SETUP_STRATEGY_RECOMMENDED, + SETUP_STRATEGY_ADVANCED, + ], + ) + + async def async_step_setup_strategy_recommended( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Recommended setup strategy: form a brand-new network.""" + return await self.async_step_form_new_network() + + async def async_step_setup_strategy_advanced( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Advanced setup strategy: let the user choose.""" + return await self.async_step_choose_formation_strategy() + + async def async_step_choose_migration_strategy( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Choose how to deal with the current radio's settings during migration.""" + return self.async_show_menu( + step_id="choose_migration_strategy", + menu_options=[ + MIGRATION_STRATEGY_RECOMMENDED, + MIGRATION_STRATEGY_ADVANCED, + ], + ) + + async def async_step_migration_strategy_recommended( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Recommended migration strategy: automatically migrate everything.""" + + # Assume the most recent backup is the correct one + self._radio_mgr.chosen_backup = self._radio_mgr.backups[0] + return await self.async_step_maybe_reset_old_radio() + + async def async_step_maybe_reset_old_radio( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Erase the old radio's network settings before migration.""" + + # Like in the options flow, pull the correct settings from the config entry + config_entries = self.hass.config_entries.async_entries(DOMAIN) + + if config_entries: + assert len(config_entries) == 1 + config_entry = config_entries[0] + + # Create a radio manager to connect to the old stick to reset it + temp_radio_mgr = ZhaRadioManager() + temp_radio_mgr.hass = self.hass + temp_radio_mgr.device_path = config_entry.data[CONF_DEVICE][ + CONF_DEVICE_PATH + ] + temp_radio_mgr.device_settings = config_entry.data[CONF_DEVICE] + temp_radio_mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]] + + await temp_radio_mgr.async_reset_adapter() + + return await self.async_step_maybe_confirm_ezsp_restore() + + async def async_step_migration_strategy_advanced( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Advanced migration strategy: let the user choose.""" + return await self.async_step_choose_formation_strategy() + async def async_step_choose_formation_strategy( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -434,7 +549,7 @@ class BaseZhaFlow(ConfigEntryBaseFlow): except ValueError: errors["base"] = "invalid_backup_json" else: - return await self.async_step_maybe_confirm_ezsp_restore() + return await self.async_step_maybe_reset_old_radio() return self.async_show_form( step_id="upload_manual_backup", @@ -474,7 +589,7 @@ class BaseZhaFlow(ConfigEntryBaseFlow): index = choices.index(user_input[CHOOSE_AUTOMATIC_BACKUP]) self._radio_mgr.chosen_backup = self._radio_mgr.backups[index] - return await self.async_step_maybe_confirm_ezsp_restore() + return await self.async_step_maybe_reset_old_radio() return self.async_show_form( step_id="choose_automatic_backup", @@ -491,16 +606,37 @@ class BaseZhaFlow(ConfigEntryBaseFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm restore for EZSP radios that require permanent IEEE writes.""" - call_step_2 = await self._radio_mgr.async_restore_backup_step_1() - if not call_step_2: - return await self._async_create_radio_entry() - if user_input is not None: - await self._radio_mgr.async_restore_backup_step_2( - user_input[OVERWRITE_COORDINATOR_IEEE] + if user_input[OVERWRITE_COORDINATOR_IEEE]: + # On confirmation, overwrite destructively + try: + await self._radio_mgr.restore_backup(overwrite_ieee=True) + except CannotWriteNetworkSettings as exc: + return self.async_abort( + reason="cannot_restore_backup", + description_placeholders={"error": str(exc)}, + ) + + return await self._async_create_radio_entry() + + # On rejection, explain why we can't restore + return self.async_abort(reason="cannot_restore_backup_no_ieee_confirm") + + # On first attempt, just try to restore nondestructively + try: + await self._radio_mgr.restore_backup() + except DestructiveWriteNetworkSettings: + # Restore cannot happen automatically, we need to ask for permission + pass + except CannotWriteNetworkSettings as exc: + return self.async_abort( + reason="cannot_restore_backup", + description_placeholders={"error": str(exc)}, ) + else: return await self._async_create_radio_entry() + # If it fails, show the form return self.async_show_form( step_id="maybe_confirm_ezsp_restore", data_schema=vol.Schema( @@ -548,24 +684,22 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a ZHA config flow start.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - return await self.async_step_choose_serial_port(user_input) async def async_step_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm a discovery.""" + self._set_confirm_only() - # Don't permit discovery if ZHA is already set up - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") + zha_config_entries = self.hass.config_entries.async_entries(DOMAIN) # Without confirmation, discovery can automatically progress into parts of the # config flow logic that interacts with hardware. - if user_input is not None or not onboarding.async_is_onboarded(self.hass): + if user_input is not None or ( + not onboarding.async_is_onboarded(self.hass) and not zha_config_entries + ): # Probe the radio type if we don't have one yet if self._radio_mgr.radio_type is None: probe_result = await self._radio_mgr.detect_radio_type() @@ -686,11 +820,13 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): self._title = title self._radio_mgr.device_path = device_path self._radio_mgr.radio_type = radio_type - self._radio_mgr.device_settings = { - CONF_DEVICE_PATH: device_path, - CONF_BAUDRATE: 115200, - CONF_FLOW_CONTROL: None, - } + self._radio_mgr.device_settings = DEVICE_SCHEMA( + { + CONF_DEVICE_PATH: device_path, + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + } + ) return await self.async_step_confirm() @@ -721,6 +857,30 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): return await self.async_step_confirm() + async def _async_create_radio_entry(self) -> ConfigFlowResult: + """Create a config entry with the current flow state.""" + + # ZHA is still single instance only, even though we use discovery to allow for + # migrating to a new radio + zha_config_entries = self.hass.config_entries.async_entries(DOMAIN) + data = await self._get_config_entry_data() + + if len(zha_config_entries) == 1: + return self.async_update_reload_and_abort( + entry=zha_config_entries[0], + title=self._title, + data=data, + reload_even_if_entry_is_unchanged=True, + reason="reconfigure_successful", + ) + if not zha_config_entries: + return self.async_create_entry( + title=self._title, + data=data, + ) + # This should never be reached + return self.async_abort(reason="single_instance_allowed") + class ZhaOptionsFlowHandler(BaseZhaFlow, OptionsFlow): """Handle an options flow.""" @@ -738,8 +898,20 @@ class ZhaOptionsFlowHandler(BaseZhaFlow, OptionsFlow): ) -> ConfigFlowResult: """Launch the options flow.""" if user_input is not None: - # OperationNotAllowed: ZHA is not running + # Perform a backup first + try: + zha_gateway = get_zha_gateway(self.hass) + except ValueError: + pass + else: + # The backup itself will be stored in `zigbee.db`, which the radio + # manager will read when the class is initialized + application_controller = zha_gateway.application_controller + await application_controller.backups.create_backup(load_devices=True) + + # Then unload the integration with suppress(OperationNotAllowed): + # OperationNotAllowed: ZHA is not running await self.hass.config_entries.async_unload(self.config_entry.entry_id) return await self.async_step_prompt_migrate_or_reconfigure() @@ -790,18 +962,11 @@ class ZhaOptionsFlowHandler(BaseZhaFlow, OptionsFlow): async def _async_create_radio_entry(self): """Re-implementation of the base flow's final step to update the config.""" - device_settings = self._radio_mgr.device_settings.copy() - device_settings[CONF_DEVICE_PATH] = await self.hass.async_add_executor_job( - usb.get_serial_by_id, self._radio_mgr.device_path - ) # Avoid creating both `.options` and `.data` by directly writing `data` here self.hass.config_entries.async_update_entry( entry=self.config_entry, - data={ - CONF_DEVICE: device_settings, - CONF_RADIO_TYPE: self._radio_mgr.radio_type.name, - }, + data=await self._get_config_entry_data(), options=self.config_entry.options, ) diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index 6a5d39bc3dbe..b2d515d785f2 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -1,4 +1,4 @@ -"""Config flow for ZHA.""" +"""ZHA radio manager.""" from __future__ import annotations @@ -29,6 +29,7 @@ from zigpy.exceptions import NetworkNotFormed from homeassistant import config_entries from homeassistant.components import usb from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service_info.usb import UsbServiceInfo from . import repairs @@ -40,22 +41,17 @@ from .const import ( ) from .helpers import get_zha_data -# Only the common radio types will be autoprobed, ordered by new device popularity. -# XBee takes too long to probe since it scans through all possible bauds and likely has -# very few users to begin with. -AUTOPROBE_RADIOS = ( - RadioType.ezsp, - RadioType.znp, - RadioType.deconz, - RadioType.zigate, -) - RECOMMENDED_RADIOS = ( RadioType.ezsp, RadioType.znp, RadioType.deconz, ) +# Only the common radio types will be autoprobed, ordered by new device popularity. +# XBee takes too long to probe since it scans through all possible bauds and likely has +# very few users to begin with. +AUTOPROBE_RADIOS = RECOMMENDED_RADIOS + CONNECT_DELAY_S = 1.0 RETRY_DELAY_S = 1.0 @@ -158,22 +154,38 @@ class ZhaRadioManager: return mgr + @property + def zigpy_database_path(self) -> str: + """Path to `zigbee.db`.""" + config = get_zha_data(self.hass).yaml_config + + return config.get( + CONF_DATABASE, + self.hass.config.path(DEFAULT_DATABASE_NAME), + ) + @contextlib.asynccontextmanager - async def connect_zigpy_app(self) -> AsyncIterator[ControllerApplication]: + async def create_zigpy_app( + self, *, connect: bool = True + ) -> AsyncIterator[ControllerApplication]: """Connect to the radio with the current config and then clean up.""" assert self.radio_type is not None config = get_zha_data(self.hass).yaml_config app_config = config.get(CONF_ZIGPY, {}).copy() - database_path = config.get( - CONF_DATABASE, - self.hass.config.path(DEFAULT_DATABASE_NAME), - ) + database_path: str | None = self.zigpy_database_path # Don't create `zigbee.db` if it doesn't already exist - if not await self.hass.async_add_executor_job(os.path.exists, database_path): - database_path = None + try: + if database_path is not None and not await self.hass.async_add_executor_job( + os.path.exists, database_path + ): + database_path = None + except OSError as error: + raise HomeAssistantError( + f"Could not read the ZHA database {database_path}: {error}" + ) from error app_config[CONF_DATABASE] = database_path app_config[CONF_DEVICE] = self.device_settings @@ -185,22 +197,45 @@ class ZhaRadioManager: ) try: + if connect: + try: + await app.connect() + except OSError as error: + raise HomeAssistantError( + f"Failed to connect to Zigbee adapter: {error}" + ) from error + yield app finally: await app.shutdown() await asyncio.sleep(CONNECT_DELAY_S) async def restore_backup( - self, backup: zigpy.backups.NetworkBackup, **kwargs: Any + self, + backup: zigpy.backups.NetworkBackup | None = None, + *, + overwrite_ieee: bool = False, + **kwargs: Any, ) -> None: """Restore the provided network backup, passing through kwargs.""" + if backup is None: + backup = self.chosen_backup + + assert backup is not None + if self.current_settings is not None and self.current_settings.supersedes( - self.chosen_backup + backup ): return - async with self.connect_zigpy_app() as app: - await app.connect() + if overwrite_ieee: + backup = _allow_overwrite_ezsp_ieee(backup) + + async with self.create_zigpy_app() as app: + await app.can_write_network_settings( + network_info=backup.network_info, + node_info=backup.node_info, + ) await app.backups.restore_backup(backup, **kwargs) @staticmethod @@ -242,15 +277,27 @@ class ZhaRadioManager: return ProbeResult.PROBING_FAILED + async def _async_read_backups_from_database( + self, + ) -> list[zigpy.backups.NetworkBackup]: + """Read the list of backups from the database, internal.""" + async with self.create_zigpy_app(connect=False) as app: + backups = app.backups.backups.copy() + backups.sort(reverse=True, key=lambda b: b.backup_time) + + return backups + + async def async_read_backups_from_database(self) -> None: + """Read the list of backups from the database.""" + self.backups = await self._async_read_backups_from_database() + async def async_load_network_settings( self, *, create_backup: bool = False ) -> zigpy.backups.NetworkBackup | None: """Connect to the radio and load its current network settings.""" backup = None - async with self.connect_zigpy_app() as app: - await app.connect() - + async with self.create_zigpy_app() as app: # Check if the stick has any settings and load them try: await app.load_network_info() @@ -273,66 +320,20 @@ class ZhaRadioManager: async def async_form_network(self) -> None: """Form a brand-new network.""" - async with self.connect_zigpy_app() as app: - await app.connect() + + # When forming a new network, we delete the ZHA database to prevent old devices + # from appearing in an unusable state + with suppress(OSError): + await self.hass.async_add_executor_job(os.remove, self.zigpy_database_path) + + async with self.create_zigpy_app() as app: await app.form_network() async def async_reset_adapter(self) -> None: """Reset the current adapter.""" - async with self.connect_zigpy_app() as app: - await app.connect() + async with self.create_zigpy_app() as app: await app.reset_network_info() - async def async_restore_backup_step_1(self) -> bool: - """Prepare restoring backup. - - Returns True if async_restore_backup_step_2 should be called. - """ - assert self.chosen_backup is not None - - if self.radio_type != RadioType.ezsp: - await self.restore_backup(self.chosen_backup) - return False - - # We have no way to partially load network settings if no network is formed - if self.current_settings is None: - # Since we are going to be restoring the backup anyways, write it to the - # radio without overwriting the IEEE but don't take a backup with these - # temporary settings - temp_backup = _prevent_overwrite_ezsp_ieee(self.chosen_backup) - await self.restore_backup(temp_backup, create_new=False) - await self.async_load_network_settings() - - assert self.current_settings is not None - - metadata = self.current_settings.network_info.metadata["ezsp"] - - if ( - self.current_settings.node_info.ieee == self.chosen_backup.node_info.ieee - or metadata["can_rewrite_custom_eui64"] - or not metadata["can_burn_userdata_custom_eui64"] - ): - # No point in prompting the user if the backup doesn't have a new IEEE - # address or if there is no way to overwrite the IEEE address a second time - await self.restore_backup(self.chosen_backup) - - return False - - return True - - async def async_restore_backup_step_2(self, overwrite_ieee: bool) -> None: - """Restore backup and optionally overwrite IEEE.""" - assert self.chosen_backup is not None - - backup = self.chosen_backup - - if overwrite_ieee: - backup = _allow_overwrite_ezsp_ieee(backup) - - # If the user declined to overwrite the IEEE *and* we wrote the backup to - # their empty radio above, restoring it again would be redundant. - await self.restore_backup(backup) - class ZhaMultiPANMigrationHelper: """Helper class for automatic migration when upgrading the firmware of a radio. @@ -442,9 +443,7 @@ class ZhaMultiPANMigrationHelper: # Restore the backup, permanently overwriting the device IEEE address for retry in range(MIGRATION_RETRIES): try: - if await self._radio_mgr.async_restore_backup_step_1(): - await self._radio_mgr.async_restore_backup_step_2(True) - + await self._radio_mgr.restore_backup(overwrite_ieee=True) break except OSError as err: if retry >= MIGRATION_RETRIES - 1: diff --git a/homeassistant/components/zha/repairs/network_settings_inconsistent.py b/homeassistant/components/zha/repairs/network_settings_inconsistent.py index ef38ebc3d47a..609dda5100be 100644 --- a/homeassistant/components/zha/repairs/network_settings_inconsistent.py +++ b/homeassistant/components/zha/repairs/network_settings_inconsistent.py @@ -136,7 +136,7 @@ class NetworkSettingsInconsistentFlow(RepairsFlow): self, user_input: dict[str, str] | None = None ) -> FlowResult: """Step to use the new settings found on the radio.""" - async with self._radio_mgr.connect_zigpy_app() as app: + async with self._radio_mgr.create_zigpy_app(connect=False) as app: app.backups.add_backup(self._new_state) await self.hass.config_entries.async_reload(self._entry_id) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 096fd591fb7a..4b28b1c426ed 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -24,17 +24,50 @@ }, "manual_port_config": { "title": "Serial port settings", - "description": "Enter the serial port settings", + "description": "ZHA was not able to automatically detect serial port settings for your adapter. This usually is an issue with the firmware or permissions.\n\nIf you are using firmware with nonstandard settings, enter the serial port settings", "data": { "path": "Serial device path", - "baudrate": "Port speed", - "flow_control": "Data flow control" + "baudrate": "Serial port speed", + "flow_control": "Serial port flow control" + }, + "data_description": { + "path": "Path to the serial port or `socket://` TCP address", + "baudrate": "Baudrate to use when communicating with the serial port, usually 115200 or 460800", + "flow_control": "Check your adapter's documentation for the correct option, usually `None` or `Hardware`" } }, "verify_radio": { "title": "Radio is not recommended", "description": "The radio you are using ({name}) is not recommended and support for it may be removed in the future. Please see the Zigbee Home Automation integration's documentation for [a list of recommended adapters]({docs_recommended_adapters_url})." }, + "choose_setup_strategy": { + "title": "Set up Zigbee", + "description": "Choose how you want to set up Zigbee. Automatic setup is recommended unless you are restoring your network from a backup or setting up an adapter with nonstandard settings.", + "menu_options": { + "setup_strategy_recommended": "Set up automatically (recommended)", + "setup_strategy_advanced": "Advanced setup" + }, + "menu_option_descriptions": { + "setup_strategy_recommended": "This is the quickest option to create a new network and get started.", + "setup_strategy_advanced": "This will let you restore from a backup." + } + }, + "choose_migration_strategy": { + "title": "Migrate to a new adapter", + "description": "Choose how you want to migrate your Zigbee network backup from your old adapter to a new one.", + "menu_options": { + "migration_strategy_recommended": "Migrate automatically (recommended)", + "migration_strategy_advanced": "Advanced migration" + }, + "menu_option_descriptions": { + "migration_strategy_recommended": "This is the quickest option to migrate to a new adapter.", + "migration_strategy_advanced": "This will let you restore a specific network backup or upload your own." + } + }, + "maybe_reset_old_radio": { + "title": "Resetting old radio", + "description": "A backup was created earlier and your old radio is being reset as part of the migration." + }, "choose_formation_strategy": { "title": "Network formation", "description": "Choose the network settings for your radio.", @@ -44,6 +77,13 @@ "reuse_settings": "Keep radio network settings", "choose_automatic_backup": "Restore an automatic backup", "upload_manual_backup": "Upload a manual backup" + }, + "menu_option_descriptions": { + "form_new_network": "This will create a new Zigbee network.", + "form_initial_network": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::form_new_network%]", + "reuse_settings": "This will let ZHA import the settings from a stick that was used with other software, migrating some of the network automatically.", + "choose_automatic_backup": "This will let you change your adapter's network settings back to a previous state, in case you have changed them.", + "upload_manual_backup": "This will let you upload a backup JSON file from ZHA or the Zigbee2MQTT `coordinator_backup.json` file." } }, "choose_automatic_backup": { @@ -76,8 +116,12 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "not_zha_device": "This device is not a ZHA device", "usb_probe_failed": "Failed to probe the USB device", + "cannot_resolve_path": "Could not resolve device path: {path}", "wrong_firmware_installed": "Your device is running the wrong firmware and cannot be used with ZHA until the correct firmware is installed. [A repair has been created]({repair_url}) with more information and instructions for how to fix this.", - "invalid_zeroconf_data": "The coordinator has invalid Zeroconf service info and cannot be identified by ZHA" + "invalid_zeroconf_data": "The coordinator has invalid Zeroconf service info and cannot be identified by ZHA", + "cannot_restore_backup": "The adapter you are restoring to does not properly support backup restoration. Please upgrade the firmware.\n\nError: {error}", + "cannot_restore_backup_no_ieee_confirm": "The adapter you are restoring to has outdated firmware and cannot write the adapter IEEE address multiple times. Please upgrade the firmware or confirm permanent overwrite in the previous step.", + "reconfigure_successful": "ZHA has successfully migrated from your old adapter to the new one. Give your Zigbee network a few minutes to stabilize.\n\nIf you no longer need the old adapter, you can now unplug it." } }, "options": { @@ -85,7 +129,7 @@ "step": { "init": { "title": "Reconfigure ZHA", - "description": "ZHA will be stopped. Do you wish to continue?" + "description": "A backup will be performed and ZHA will be stopped. Do you wish to continue?" }, "prompt_migrate_or_reconfigure": { "title": "Migrate or re-configure", @@ -93,6 +137,10 @@ "menu_options": { "intent_migrate": "Migrate to a new radio", "intent_reconfigure": "Re-configure the current radio" + }, + "menu_option_descriptions": { + "intent_migrate": "This will help you migrate your Zigbee network from your old radio to a new one.", + "intent_reconfigure": "This will let you change the serial port for your current Zigbee radio." } }, "intent_migrate": { @@ -130,6 +178,18 @@ "title": "[%key:component::zha::config::step::verify_radio::title%]", "description": "[%key:component::zha::config::step::verify_radio::description%]" }, + "choose_migration_strategy": { + "title": "[%key:component::zha::config::step::choose_migration_strategy::title%]", + "description": "[%key:component::zha::config::step::choose_migration_strategy::description%]", + "menu_options": { + "migration_strategy_recommended": "[%key:component::zha::config::step::choose_migration_strategy::menu_options::migration_strategy_recommended%]", + "migration_strategy_advanced": "[%key:component::zha::config::step::choose_migration_strategy::menu_options::migration_strategy_advanced%]" + }, + "menu_option_descriptions": { + "migration_strategy_recommended": "[%key:component::zha::config::step::choose_migration_strategy::menu_option_descriptions::migration_strategy_recommended%]", + "migration_strategy_advanced": "[%key:component::zha::config::step::choose_migration_strategy::menu_option_descriptions::migration_strategy_advanced%]" + } + }, "choose_formation_strategy": { "title": "[%key:component::zha::config::step::choose_formation_strategy::title%]", "description": "[%key:component::zha::config::step::choose_formation_strategy::description%]", @@ -139,6 +199,13 @@ "reuse_settings": "[%key:component::zha::config::step::choose_formation_strategy::menu_options::reuse_settings%]", "choose_automatic_backup": "[%key:component::zha::config::step::choose_formation_strategy::menu_options::choose_automatic_backup%]", "upload_manual_backup": "[%key:component::zha::config::step::choose_formation_strategy::menu_options::upload_manual_backup%]" + }, + "menu_option_descriptions": { + "form_new_network": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::form_new_network%]", + "form_initial_network": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::form_new_network%]", + "reuse_settings": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::reuse_settings%]", + "choose_automatic_backup": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::choose_automatic_backup%]", + "upload_manual_backup": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::upload_manual_backup%]" } }, "choose_automatic_backup": { @@ -168,10 +235,12 @@ "invalid_backup_json": "[%key:component::zha::config::error::invalid_backup_json%]" }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "not_zha_device": "[%key:component::zha::config::abort::not_zha_device%]", "usb_probe_failed": "[%key:component::zha::config::abort::usb_probe_failed%]", - "wrong_firmware_installed": "[%key:component::zha::config::abort::wrong_firmware_installed%]" + "cannot_resolve_path": "[%key:component::zha::config::abort::cannot_resolve_path%]", + "wrong_firmware_installed": "[%key:component::zha::config::abort::wrong_firmware_installed%]", + "cannot_restore_backup": "[%key:component::zha::config::abort::cannot_restore_backup%]", + "cannot_restore_backup_no_ieee_confirm": "[%key:component::zha::config::abort::cannot_restore_backup_no_ieee_confirm%]" } }, "config_panel": { @@ -532,6 +601,10 @@ "menu_options": { "use_new_settings": "Keep the new settings", "restore_old_settings": "Restore backup (recommended)" + }, + "menu_option_descriptions": { + "use_new_settings": "This will keep the new settings written to the stick. Only choose this option if you have intentionally changed settings.", + "restore_old_settings": "This will restore your network settings back to the last working state." } } } diff --git a/tests/components/homeassistant_connect_zbt2/conftest.py b/tests/components/homeassistant_connect_zbt2/conftest.py index d6b8fa09a3f5..2a4d349debe3 100644 --- a/tests/components/homeassistant_connect_zbt2/conftest.py +++ b/tests/components/homeassistant_connect_zbt2/conftest.py @@ -27,7 +27,7 @@ def mock_zha(): with ( patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.create_zigpy_app", return_value=mock_connect_app, ), patch( diff --git a/tests/components/homeassistant_hardware/conftest.py b/tests/components/homeassistant_hardware/conftest.py index ddf18305b2a4..9da3371bfae1 100644 --- a/tests/components/homeassistant_hardware/conftest.py +++ b/tests/components/homeassistant_hardware/conftest.py @@ -27,7 +27,7 @@ def mock_zha_config_flow_setup() -> Generator[None]: side_effect=mock_probe, ), patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.create_zigpy_app", return_value=mock_connect_app, ), patch( diff --git a/tests/components/homeassistant_sky_connect/conftest.py b/tests/components/homeassistant_sky_connect/conftest.py index 89ec292d8796..e71a86384c13 100644 --- a/tests/components/homeassistant_sky_connect/conftest.py +++ b/tests/components/homeassistant_sky_connect/conftest.py @@ -27,7 +27,7 @@ def mock_zha(): with ( patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.create_zigpy_app", return_value=mock_connect_app, ), patch( diff --git a/tests/components/homeassistant_yellow/conftest.py b/tests/components/homeassistant_yellow/conftest.py index 7247c7da4e2a..ef89f5ba3305 100644 --- a/tests/components/homeassistant_yellow/conftest.py +++ b/tests/components/homeassistant_yellow/conftest.py @@ -27,7 +27,7 @@ def mock_zha_config_flow_setup() -> Generator[None]: side_effect=mock_probe, ), patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.create_zigpy_app", return_value=mock_connect_app, ), patch( diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py index 00e3383cf77a..7bff7f10c658 100644 --- a/tests/components/homeassistant_yellow/test_init.py +++ b/tests/components/homeassistant_yellow/test_init.py @@ -71,10 +71,16 @@ async def test_setup_entry( if num_entries > 0: zha_flows = hass.config_entries.flow.async_progress_by_handler("zha") assert len(zha_flows) == 1 - assert zha_flows[0]["step_id"] == "choose_formation_strategy" + assert zha_flows[0]["step_id"] == "choose_setup_strategy" + + setup_result = await hass.config_entries.flow.async_configure( + zha_flows[0]["flow_id"], + user_input={"next_step_id": zha.config_flow.SETUP_STRATEGY_ADVANCED}, + ) + assert setup_result["step_id"] == "choose_formation_strategy" await hass.config_entries.flow.async_configure( - zha_flows[0]["flow_id"], + setup_result["flow_id"], user_input={"next_step_id": zha.config_flow.FORMATION_REUSE_SETTINGS}, ) await hass.async_block_till_done() @@ -117,10 +123,16 @@ async def test_setup_zha(hass: HomeAssistant, addon_store_info) -> None: # Finish setting up ZHA zha_flows = hass.config_entries.flow.async_progress_by_handler("zha") assert len(zha_flows) == 1 - assert zha_flows[0]["step_id"] == "choose_formation_strategy" + assert zha_flows[0]["step_id"] == "choose_setup_strategy" + + setup_result = await hass.config_entries.flow.async_configure( + zha_flows[0]["flow_id"], + user_input={"next_step_id": zha.config_flow.SETUP_STRATEGY_ADVANCED}, + ) + assert setup_result["step_id"] == "choose_formation_strategy" await hass.config_entries.flow.async_configure( - zha_flows[0]["flow_id"], + setup_result["flow_id"], user_input={"next_step_id": zha.config_flow.FORMATION_REUSE_SETTINGS}, ) await hass.async_block_till_done() diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index ff939180fbb0..70419a4b503e 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -1,7 +1,6 @@ """Tests for ZHA config flow.""" from collections.abc import Callable, Coroutine, Generator -import copy from datetime import timedelta from ipaddress import ip_address import json @@ -16,7 +15,11 @@ from zigpy.backups import BackupManager import zigpy.config from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH, SCHEMA_DEVICE import zigpy.device -from zigpy.exceptions import NetworkNotFormed +from zigpy.exceptions import ( + CannotWriteNetworkSettings, + DestructiveWriteNetworkSettings, + NetworkNotFormed, +) import zigpy.types from homeassistant import config_entries @@ -29,7 +32,7 @@ from homeassistant.components.zha.const import ( DOMAIN, EZSP_OVERWRITE_EUI64, ) -from homeassistant.components.zha.radio_manager import ProbeResult +from homeassistant.components.zha.radio_manager import ProbeResult, ZhaRadioManager from homeassistant.config_entries import ( SOURCE_SSDP, SOURCE_USB, @@ -268,11 +271,11 @@ async def test_zeroconf_discovery( ) assert result_confirm["type"] is FlowResultType.MENU - assert result_confirm["step_id"] == "choose_formation_strategy" + assert result_confirm["step_id"] == "choose_setup_strategy" result_form = await hass.config_entries.flow.async_configure( result_confirm["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, + user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED}, ) await hass.async_block_till_done() @@ -322,11 +325,11 @@ async def test_legacy_zeroconf_discovery_zigate( ) assert result_confirm["type"] is FlowResultType.MENU - assert result_confirm["step_id"] == "choose_formation_strategy" + assert result_confirm["step_id"] == "choose_setup_strategy" result_form = await hass.config_entries.flow.async_configure( result_confirm["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, + user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED}, ) await hass.async_block_till_done() @@ -426,9 +429,9 @@ async def test_legacy_zeroconf_discovery_confirm_final_abort_if_entries( flow["flow_id"], user_input={} ) - # Config will fail - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" + # Now prompts to migrate instead of aborting + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "choose_setup_strategy" @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) @@ -456,12 +459,12 @@ async def test_discovery_via_usb(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] is FlowResultType.MENU - assert result2["step_id"] == "choose_formation_strategy" + assert result2["step_id"] == "choose_setup_strategy" with patch("homeassistant.components.zha.async_setup_entry", return_value=True): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, + user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED}, ) await hass.async_block_till_done() @@ -477,56 +480,6 @@ async def test_discovery_via_usb(hass: HomeAssistant) -> None: } -@patch(f"zigpy_zigate.{PROBE_FUNCTION_PATH}", return_value=True) -async def test_zigate_discovery_via_usb(probe_mock, hass: HomeAssistant) -> None: - """Test zigate usb flow -- radio detected.""" - discovery_info = UsbServiceInfo( - device="/dev/ttyZIGBEE", - pid="0403", - vid="6015", - serial_number="1234", - description="zigate radio", - manufacturer="test", - ) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USB}, data=discovery_info - ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - assert result2["step_id"] == "verify_radio" - - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - await hass.async_block_till_done() - - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "choose_formation_strategy" - - with patch("homeassistant.components.zha.async_setup_entry", return_value=True): - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, - ) - await hass.async_block_till_done() - - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["title"] == "zigate radio" - assert result4["data"] == { - "device": { - "path": "/dev/ttyZIGBEE", - "baudrate": 115200, - "flow_control": None, - }, - CONF_RADIO_TYPE: "zigate", - } - - @patch( "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", AsyncMock(return_value=ProbeResult.PROBING_FAILED), @@ -574,13 +527,170 @@ async def test_discovery_via_usb_already_setup(hass: HomeAssistant) -> None: description="zigbee radio", manufacturer="test", ) - result = await hass.config_entries.flow.async_init( + init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USB}, data=discovery_info ) await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" + confirm_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={}, + ) + + # When we have an existing config entry, we migrate + assert confirm_result["type"] is FlowResultType.MENU + assert confirm_result["step_id"] == "choose_migration_strategy" + + +@patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_migration_strategy_recommended( + hass: HomeAssistant, backup, mock_app +) -> None: + """Test automatic migration.""" + entry = MockConfigEntry( + version=config_flow.ZhaConfigFlowHandler.VERSION, + domain=DOMAIN, + data={ + CONF_DEVICE: { + CONF_DEVICE_PATH: "/dev/ttyUSB0", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + }, + CONF_RADIO_TYPE: "znp", + }, + ) + entry.add_to_hass(hass) + + discovery_info = UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) + + with patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager._async_read_backups_from_database", + return_value=[backup], + ): + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USB}, data=discovery_info + ) + + result_confirm = await hass.config_entries.flow.async_configure( + result_init["flow_id"], user_input={} + ) + + assert result_confirm["step_id"] == "choose_migration_strategy" + + with patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", + ) as mock_restore_backup: + result_recommended = await hass.config_entries.flow.async_configure( + result_confirm["flow_id"], + user_input={"next_step_id": config_flow.MIGRATION_STRATEGY_RECOMMENDED}, + ) + + assert result_recommended["type"] is FlowResultType.ABORT + assert result_recommended["reason"] == "reconfigure_successful" + mock_restore_backup.assert_called_once() + + +@patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_migration_strategy_recommended_cannot_write( + hass: HomeAssistant, backup, mock_app +) -> None: + """Test recommended migration with a write failure.""" + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}, + CONF_RADIO_TYPE: "ezsp", + }, + ).add_to_hass(hass) + + discovery_info = UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USB}, data=discovery_info + ) + + with patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager._async_read_backups_from_database", + return_value=[backup], + ): + result_confirm = await hass.config_entries.flow.async_configure( + result_init["flow_id"], user_input={} + ) + + assert result_confirm["step_id"] == "choose_migration_strategy" + + with patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", + side_effect=CannotWriteNetworkSettings("test error"), + ) as mock_restore_backup: + result_recommended = await hass.config_entries.flow.async_configure( + result_confirm["flow_id"], + user_input={"next_step_id": config_flow.MIGRATION_STRATEGY_RECOMMENDED}, + ) + + assert mock_restore_backup.call_count == 1 + assert result_recommended["type"] is FlowResultType.ABORT + assert result_recommended["reason"] == "cannot_restore_backup" + assert "test error" in result_recommended["description_placeholders"]["error"] + + +@patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_multiple_zha_entries_aborts(hass: HomeAssistant, mock_app) -> None: + """Test flow aborts if there are multiple ZHA config entries.""" + MockConfigEntry( + domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} + ).add_to_hass(hass) + MockConfigEntry( + domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB2"}} + ).add_to_hass(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 2 + + discovery_info = UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USB}, data=discovery_info + ) + + result_confirm = await hass.config_entries.flow.async_configure( + result_init["flow_id"], user_input={} + ) + + assert result_confirm["step_id"] == "choose_migration_strategy" + + result_recommended = await hass.config_entries.flow.async_configure( + result_confirm["flow_id"], + user_input={"next_step_id": config_flow.MIGRATION_STRATEGY_ADVANCED}, + ) + + result_reuse = await hass.config_entries.flow.async_configure( + result_recommended["flow_id"], + user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, + ) + + assert result_reuse["type"] is FlowResultType.ABORT + assert result_reuse["reason"] == "single_instance_allowed" @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @@ -751,13 +861,19 @@ async def test_legacy_zeroconf_discovery_already_setup(hass: HomeAssistant) -> N domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} ).add_to_hass(hass) - result = await hass.config_entries.flow.async_init( + init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=service_info ) await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" + confirm_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={}, + ) + + # When we have an existing config entry, we migrate + assert confirm_result["type"] is FlowResultType.MENU + assert confirm_result["step_id"] == "choose_migration_strategy" @patch( @@ -779,12 +895,12 @@ async def test_user_flow(hass: HomeAssistant) -> None: }, ) assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "choose_formation_strategy" + assert result["step_id"] == "choose_setup_strategy" with patch("homeassistant.components.zha.async_setup_entry", return_value=True): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, + user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED}, ) await hass.async_block_till_done() @@ -875,19 +991,6 @@ async def test_pick_radio_flow(hass: HomeAssistant, radio_type) -> None: assert result["step_id"] == "manual_port_config" -async def test_user_flow_existing_config_entry(hass: HomeAssistant) -> None: - """Test if config entry already exists.""" - MockConfigEntry( - domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} - ).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_USER} - ) - - assert result["type"] is FlowResultType.ABORT - - @patch(f"bellows.{PROBE_FUNCTION_PATH}", return_value=False) @patch(f"zigpy_deconz.{PROBE_FUNCTION_PATH}", return_value=False) @patch(f"zigpy_zigate.{PROBE_FUNCTION_PATH}", return_value=False) @@ -956,7 +1059,11 @@ async def test_user_port_config_fail(probe_mock, hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB33"}, + user_input={ + CONF_DEVICE_PATH: "/dev/ttyUSB33", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: "none", + }, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "manual_port_config" @@ -981,11 +1088,11 @@ async def test_user_port_config(probe_mock, hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "choose_formation_strategy" + assert result["step_id"] == "choose_setup_strategy" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, + user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED}, ) await hass.async_block_till_done() @@ -1026,21 +1133,21 @@ async def test_hardware(onboarded, hass: HomeAssistant) -> None: result1["flow_id"], user_input={}, ) + + assert result2["type"] is FlowResultType.MENU + assert result2["step_id"] == "choose_setup_strategy" + + result_create = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED}, + ) + await hass.async_block_till_done() else: # No need to confirm - result2 = result1 + result_create = result1 - assert result2["type"] is FlowResultType.MENU - assert result2["step_id"] == "choose_formation_strategy" - - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, - ) - await hass.async_block_till_done() - - assert result3["title"] == "Yellow" - assert result3["data"] == { + assert result_create["title"] == "Yellow" + assert result_create["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, CONF_FLOW_CONTROL: "hardware", @@ -1050,30 +1157,6 @@ async def test_hardware(onboarded, hass: HomeAssistant) -> None: } -async def test_hardware_already_setup(hass: HomeAssistant) -> None: - """Test hardware flow -- already setup.""" - - MockConfigEntry( - domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} - ).add_to_hass(hass) - - data = { - "name": "Yellow", - "radio_type": "efr32", - "port": { - "path": "/dev/ttyAMA1", - "baudrate": 115200, - "flow_control": "hardware", - }, - } - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_HARDWARE}, data=data - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" - - @pytest.mark.parametrize( "data", [None, {}, {"radio_type": "best_radio"}, {"radio_type": "efr32"}] ) @@ -1110,7 +1193,7 @@ def test_prevent_overwrite_ezsp_ieee() -> None: @pytest.fixture -def pick_radio( +def advanced_pick_radio( hass: HomeAssistant, ) -> Generator[RadioPicker]: """Fixture for the first step of the config flow (where a radio is picked).""" @@ -1132,9 +1215,17 @@ def pick_radio( ) assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "choose_formation_strategy" + assert result["step_id"] == "choose_setup_strategy" - return result, port + advanced_strategy_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"next_step_id": config_flow.SETUP_STRATEGY_ADVANCED}, + ) + + assert advanced_strategy_result["type"] == FlowResultType.MENU + assert advanced_strategy_result["step_id"] == "choose_formation_strategy" + + return advanced_strategy_result p1 = patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) p2 = patch("homeassistant.components.zha.async_setup_entry") @@ -1144,12 +1235,12 @@ def pick_radio( async def test_strategy_no_network_settings( - pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant ) -> None: """Test formation strategy when no network settings are present.""" mock_app.load_network_info = MagicMock(side_effect=NetworkNotFormed()) - result, _port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) assert ( config_flow.FORMATION_REUSE_SETTINGS not in result["data_schema"].schema["next_step_id"].container @@ -1157,10 +1248,10 @@ async def test_strategy_no_network_settings( async def test_formation_strategy_form_new_network( - pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant ) -> None: """Test forming a new network.""" - result, _port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1175,12 +1266,12 @@ async def test_formation_strategy_form_new_network( async def test_formation_strategy_form_initial_network( - pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant ) -> None: """Test forming a new network, with no previous settings on the radio.""" mock_app.load_network_info = AsyncMock(side_effect=NetworkNotFormed()) - result, _port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": config_flow.FORMATION_FORM_INITIAL_NETWORK}, @@ -1231,10 +1322,10 @@ async def test_onboarding_auto_formation_new_hardware( async def test_formation_strategy_reuse_settings( - pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant ) -> None: """Test reusing existing network settings.""" - result, _port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1265,12 +1356,12 @@ def test_parse_uploaded_backup(process_mock) -> None: @patch("homeassistant.components.zha.radio_manager._allow_overwrite_ezsp_ieee") async def test_formation_strategy_restore_manual_backup_non_ezsp( allow_overwrite_ieee_mock, - pick_radio: RadioPicker, + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant, ) -> None: """Test restoring a manual backup on non-EZSP coordinators.""" - result, _port = await pick_radio(RadioType.znp) + result = await advanced_pick_radio(RadioType.znp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1300,13 +1391,13 @@ async def test_formation_strategy_restore_manual_backup_non_ezsp( @patch("homeassistant.components.zha.radio_manager._allow_overwrite_ezsp_ieee") async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp( allow_overwrite_ieee_mock, - pick_radio: RadioPicker, + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, backup, hass: HomeAssistant, ) -> None: """Test restoring a manual backup on EZSP coordinators (overwrite IEEE).""" - result, _port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1317,39 +1408,53 @@ async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp( assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "upload_manual_backup" - with patch( - "homeassistant.components.zha.config_flow.ZhaConfigFlowHandler._parse_uploaded_backup", - return_value=backup, + with ( + patch( + "homeassistant.components.zha.config_flow.ZhaConfigFlowHandler._parse_uploaded_backup", + return_value=backup, + ), + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", + side_effect=[ + DestructiveWriteNetworkSettings("Radio IEEE change is permanent"), + None, + ], + ) as mock_restore_backup, ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], user_input={config_flow.UPLOADED_BACKUP_FILE: str(uuid.uuid4())}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "maybe_confirm_ezsp_restore" + assert mock_restore_backup.call_count == 1 + assert not mock_restore_backup.mock_calls[0].kwargs.get("overwrite_ieee") + mock_restore_backup.reset_mock() - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True}, - ) + # The radio requires user confirmation for restore + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "maybe_confirm_ezsp_restore" - allow_overwrite_ieee_mock.assert_called_once() - mock_app.backups.restore_backup.assert_called_once() + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True}, + ) assert result4["type"] is FlowResultType.CREATE_ENTRY assert result4["data"][CONF_RADIO_TYPE] == "ezsp" + assert mock_restore_backup.call_count == 1 + assert mock_restore_backup.mock_calls[0].kwargs["overwrite_ieee"] is True + @patch("homeassistant.components.zha.radio_manager._allow_overwrite_ezsp_ieee") async def test_formation_strategy_restore_manual_backup_ezsp( allow_overwrite_ieee_mock, - pick_radio: RadioPicker, + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant, ) -> None: """Test restoring a manual backup on EZSP coordinators (don't overwrite IEEE).""" - result, _port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1360,37 +1465,48 @@ async def test_formation_strategy_restore_manual_backup_ezsp( assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "upload_manual_backup" - backup = zigpy.backups.NetworkBackup() - - with patch( - "homeassistant.components.zha.config_flow.ZhaConfigFlowHandler._parse_uploaded_backup", - return_value=backup, + with ( + patch( + "homeassistant.components.zha.config_flow.ZhaConfigFlowHandler._parse_uploaded_backup", + return_value=backup, + ), + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", + side_effect=[ + DestructiveWriteNetworkSettings("Radio IEEE change is permanent"), + None, + ], + ) as mock_restore_backup, ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], user_input={config_flow.UPLOADED_BACKUP_FILE: str(uuid.uuid4())}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "maybe_confirm_ezsp_restore" + assert mock_restore_backup.call_count == 1 + assert not mock_restore_backup.mock_calls[0].kwargs.get("overwrite_ieee") + mock_restore_backup.reset_mock() - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: False}, - ) + # The radio requires user confirmation for restore + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "maybe_confirm_ezsp_restore" - allow_overwrite_ieee_mock.assert_not_called() - mock_app.backups.restore_backup.assert_called_once_with(backup) + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + # We do not accept + user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: False}, + ) - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["data"][CONF_RADIO_TYPE] == "ezsp" + assert result4["type"] is FlowResultType.ABORT + assert result4["reason"] == "cannot_restore_backup_no_ieee_confirm" + assert mock_restore_backup.call_count == 0 async def test_formation_strategy_restore_manual_backup_invalid_upload( - pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant ) -> None: """Test restoring a manual backup but an invalid file is uploaded.""" - result, _port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1439,7 +1555,10 @@ def test_format_backup_choice() -> None: ) @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_formation_strategy_restore_automatic_backup_ezsp( - pick_radio: RadioPicker, mock_app: AsyncMock, make_backup, hass: HomeAssistant + advanced_pick_radio: RadioPicker, + mock_app: AsyncMock, + make_backup, + hass: HomeAssistant, ) -> None: """Test restoring an automatic backup (EZSP radio).""" mock_app.backups.backups = [ @@ -1450,7 +1569,7 @@ async def test_formation_strategy_restore_automatic_backup_ezsp( backup = mock_app.backups.backups[1] # pick the second one backup.is_compatible_with = MagicMock(return_value=False) - result, _port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": (config_flow.FORMATION_CHOOSE_AUTOMATIC_BACKUP)}, @@ -1467,18 +1586,10 @@ async def test_formation_strategy_restore_automatic_backup_ezsp( }, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "maybe_confirm_ezsp_restore" - - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True}, - ) - mock_app.backups.restore_backup.assert_called_once() - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["data"][CONF_RADIO_TYPE] == "ezsp" + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["data"][CONF_RADIO_TYPE] == "ezsp" @patch( @@ -1489,7 +1600,7 @@ async def test_formation_strategy_restore_automatic_backup_ezsp( @pytest.mark.parametrize("is_advanced", [True, False]) async def test_formation_strategy_restore_automatic_backup_non_ezsp( is_advanced, - pick_radio: RadioPicker, + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, make_backup, hass: HomeAssistant, @@ -1503,7 +1614,7 @@ async def test_formation_strategy_restore_automatic_backup_non_ezsp( backup = mock_app.backups.backups[1] # pick the second one backup.is_compatible_with = MagicMock(return_value=False) - result, _port = await pick_radio(RadioType.znp) + result = await advanced_pick_radio(RadioType.znp) with patch( "homeassistant.config_entries.ConfigFlow.show_advanced_options", @@ -1543,54 +1654,51 @@ async def test_formation_strategy_restore_automatic_backup_non_ezsp( assert result3["data"][CONF_RADIO_TYPE] == "znp" -@patch("homeassistant.components.zha.radio_manager._allow_overwrite_ezsp_ieee") -async def test_ezsp_restore_without_settings_change_ieee( - allow_overwrite_ieee_mock, - pick_radio: RadioPicker, - mock_app: AsyncMock, - backup, - hass: HomeAssistant, +@patch("homeassistant.components.zha.async_setup_entry", return_value=True) +async def test_options_flow_creates_backup( + async_setup_entry, hass: HomeAssistant, mock_app ) -> None: - """Test a manual backup on EZSP coordinators without settings (no IEEE write).""" - # Fail to load settings - with patch.object( - mock_app, "load_network_info", MagicMock(side_effect=NetworkNotFormed()) - ): - result, _port = await pick_radio(RadioType.ezsp) - - # Set the network state, it'll be picked up later after the load "succeeds" - mock_app.state.node_info = backup.node_info - mock_app.state.network_info = copy.deepcopy(backup.network_info) - mock_app.state.network_info.network_key.tx_counter += 10000 - mock_app.state.network_info.metadata["ezsp"] = {} - - # Include the overwrite option, just in case someone uploads a backup with it - backup.network_info.metadata["ezsp"] = {EZSP_OVERWRITE_EUI64: True} - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_UPLOAD_MANUAL_BACKUP}, + """Test options flow creates a backup.""" + entry = MockConfigEntry( + version=config_flow.ZhaConfigFlowHandler.VERSION, + domain=DOMAIN, + data={ + CONF_DEVICE: { + CONF_DEVICE_PATH: "/dev/ttyUSB0", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + }, + CONF_RADIO_TYPE: "znp", + }, ) - await hass.async_block_till_done() + entry.add_to_hass(hass) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "upload_manual_backup" + zha_gateway = MagicMock() + zha_gateway.application_controller = mock_app + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED with patch( - "homeassistant.components.zha.config_flow.ZhaConfigFlowHandler._parse_uploaded_backup", - return_value=backup, + "homeassistant.components.zha.config_flow.get_zha_gateway", + return_value=zha_gateway, ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - user_input={config_flow.UPLOADED_BACKUP_FILE: str(uuid.uuid4())}, - ) + flow = await hass.config_entries.options.async_init(entry.entry_id) - # We wrote settings when connecting - allow_overwrite_ieee_mock.assert_not_called() - mock_app.backups.restore_backup.assert_called_once_with(backup, create_new=False) + assert flow["step_id"] == "init" - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["data"][CONF_RADIO_TYPE] == "ezsp" + with patch( + "homeassistant.config_entries.ConfigEntries.async_unload", return_value=True + ) as mock_async_unload: + result = await hass.config_entries.options.async_configure( + flow["flow_id"], user_input={} + ) + + mock_app.backups.create_backup.assert_called_once_with(load_devices=True) + mock_async_unload.assert_called_once_with(entry.entry_id) + + assert result["step_id"] == "prompt_migrate_or_reconfigure" @pytest.mark.parametrize( @@ -1677,7 +1785,16 @@ async def test_options_flow_defaults( # The defaults match our current settings assert result4["step_id"] == "manual_port_config" - assert result4["data_schema"]({}) == entry.data[CONF_DEVICE] + assert entry.data[CONF_DEVICE] == { + "path": "/dev/ttyUSB0", + "baudrate": 12345, + "flow_control": None, + } + assert result4["data_schema"]({}) == { + "path": "/dev/ttyUSB0", + "baudrate": 12345, + "flow_control": "none", + } with patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)): # Change the serial port path @@ -1692,18 +1809,24 @@ async def test_options_flow_defaults( ) # The radio has been detected, we can move on to creating the config entry - assert result5["step_id"] == "choose_formation_strategy" + assert result5["step_id"] == "choose_migration_strategy" async_setup_entry.assert_not_called() result6 = await hass.config_entries.options.async_configure( - result1["flow_id"], + result5["flow_id"], + user_input={"next_step_id": config_flow.MIGRATION_STRATEGY_ADVANCED}, + ) + await hass.async_block_till_done() + + result7 = await hass.config_entries.options.async_configure( + result6["flow_id"], user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, ) await hass.async_block_till_done() - assert result6["type"] is FlowResultType.CREATE_ENTRY - assert result6["data"] == {} + assert result7["type"] is FlowResultType.CREATE_ENTRY + assert result7["data"] == {} # The updated entry contains correct settings assert entry.data == { @@ -1784,14 +1907,23 @@ async def test_options_flow_defaults_socket(hass: HomeAssistant) -> None: # The defaults match our current settings assert result4["step_id"] == "manual_port_config" - assert result4["data_schema"]({}) == entry.data[CONF_DEVICE] + assert entry.data[CONF_DEVICE] == { + "path": "socket://localhost:5678", + "baudrate": 12345, + "flow_control": None, + } + assert result4["data_schema"]({}) == { + "path": "socket://localhost:5678", + "baudrate": 12345, + "flow_control": "none", + } with patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)): result5 = await hass.config_entries.options.async_configure( flow["flow_id"], user_input={} ) - assert result5["step_id"] == "choose_formation_strategy" + assert result5["step_id"] == "choose_migration_strategy" @patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) @@ -2061,3 +2193,174 @@ async def test_migration_ti_cc_to_znp( assert config_entry.version > 2 assert config_entry.data[CONF_RADIO_TYPE] == new_type + + +@patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_migration_resets_old_radio( + hass: HomeAssistant, backup, mock_app +) -> None: + """Test that the old radio is reset during migration.""" + entry = MockConfigEntry( + version=config_flow.ZhaConfigFlowHandler.VERSION, + domain=DOMAIN, + data={ + CONF_DEVICE: { + CONF_DEVICE_PATH: "/dev/ttyUSB0", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + }, + CONF_RADIO_TYPE: "ezsp", + }, + ) + entry.add_to_hass(hass) + + discovery_info = UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) + + mock_temp_radio_mgr = AsyncMock() + mock_temp_radio_mgr.async_reset_adapter = AsyncMock() + + with ( + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager._async_read_backups_from_database", + return_value=[backup], + ), + patch( + "homeassistant.components.zha.config_flow.ZhaRadioManager", + side_effect=[ZhaRadioManager(), mock_temp_radio_mgr], + ), + ): + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USB}, data=discovery_info + ) + + result_confirm = await hass.config_entries.flow.async_configure( + result_init["flow_id"], user_input={} + ) + + assert result_confirm["step_id"] == "choose_migration_strategy" + + result_recommended = await hass.config_entries.flow.async_configure( + result_confirm["flow_id"], + user_input={"next_step_id": config_flow.MIGRATION_STRATEGY_RECOMMENDED}, + ) + + assert result_recommended["type"] is FlowResultType.ABORT + assert result_recommended["reason"] == "reconfigure_successful" + + # We reset the old radio + assert mock_temp_radio_mgr.async_reset_adapter.call_count == 1 + + # It should be configured with the old radio's settings + assert mock_temp_radio_mgr.radio_type == RadioType.ezsp + assert mock_temp_radio_mgr.device_path == "/dev/ttyUSB0" + assert mock_temp_radio_mgr.device_settings == { + CONF_DEVICE_PATH: "/dev/ttyUSB0", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + } + + +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +@patch(f"bellows.{PROBE_FUNCTION_PATH}", return_value=True) +async def test_config_flow_serial_resolution_oserror( + probe_mock, hass: HomeAssistant +) -> None: + """Test that OSError during serial port resolution is handled.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "manual_pick_radio_type"}, + data={CONF_RADIO_TYPE: RadioType.ezsp.description}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB33"}, + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "choose_setup_strategy" + + with ( + patch( + "homeassistant.components.usb.get_serial_by_id", + side_effect=OSError("Test error"), + ), + ): + setup_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED}, + ) + + assert setup_result["type"] is FlowResultType.ABORT + assert setup_result["reason"] == "cannot_resolve_path" + assert setup_result["description_placeholders"] == {"path": "/dev/ttyUSB33"} + + +@patch("homeassistant.components.zha.radio_manager._allow_overwrite_ezsp_ieee") +async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp_write_fail( + allow_overwrite_ieee_mock, + advanced_pick_radio: RadioPicker, + mock_app: AsyncMock, + backup, + hass: HomeAssistant, +) -> None: + """Test restoring a manual backup on EZSP coordinators (overwrite IEEE) with a write failure.""" + advanced_strategy_result = await advanced_pick_radio(RadioType.ezsp) + + upload_backup_result = await hass.config_entries.flow.async_configure( + advanced_strategy_result["flow_id"], + user_input={"next_step_id": config_flow.FORMATION_UPLOAD_MANUAL_BACKUP}, + ) + await hass.async_block_till_done() + + assert upload_backup_result["type"] is FlowResultType.FORM + assert upload_backup_result["step_id"] == "upload_manual_backup" + + with ( + patch( + "homeassistant.components.zha.config_flow.ZhaConfigFlowHandler._parse_uploaded_backup", + return_value=backup, + ), + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", + side_effect=[ + DestructiveWriteNetworkSettings("Radio IEEE change is permanent"), + CannotWriteNetworkSettings("Failed to write settings"), + ], + ) as mock_restore_backup, + ): + confirm_restore_result = await hass.config_entries.flow.async_configure( + upload_backup_result["flow_id"], + user_input={config_flow.UPLOADED_BACKUP_FILE: str(uuid.uuid4())}, + ) + + assert mock_restore_backup.call_count == 1 + assert not mock_restore_backup.mock_calls[0].kwargs.get("overwrite_ieee") + mock_restore_backup.reset_mock() + + # The radio requires user confirmation for restore + assert confirm_restore_result["type"] is FlowResultType.FORM + assert confirm_restore_result["step_id"] == "maybe_confirm_ezsp_restore" + + final_result = await hass.config_entries.flow.async_configure( + confirm_restore_result["flow_id"], + user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True}, + ) + + assert final_result["type"] is FlowResultType.ABORT + assert final_result["reason"] == "cannot_restore_backup" + assert ( + "Failed to write settings" in final_result["description_placeholders"]["error"] + ) + + assert mock_restore_backup.call_count == 1 + assert mock_restore_backup.mock_calls[0].kwargs["overwrite_ieee"] is True diff --git a/tests/components/zha/test_radio_manager.py b/tests/components/zha/test_radio_manager.py index 59494dd0d093..c8086cc49d93 100644 --- a/tests/components/zha/test_radio_manager.py +++ b/tests/components/zha/test_radio_manager.py @@ -16,6 +16,7 @@ from homeassistant.components.zha.const import DOMAIN from homeassistant.components.zha.radio_manager import ProbeResult, ZhaRadioManager from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service_info.usb import UsbServiceInfo from tests.common import MockConfigEntry @@ -88,7 +89,7 @@ def com_port(device="/dev/ttyUSB1234"): @pytest.fixture -def mock_connect_zigpy_app() -> Generator[MagicMock]: +def mock_create_zigpy_app() -> Generator[MagicMock]: """Mock the radio connection.""" mock_connect_app = MagicMock() @@ -98,7 +99,7 @@ def mock_connect_zigpy_app() -> Generator[MagicMock]: ) with patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.create_zigpy_app", return_value=mock_connect_app, ): yield mock_connect_app @@ -107,7 +108,7 @@ def mock_connect_zigpy_app() -> Generator[MagicMock]: @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_migrate_matching_port( hass: HomeAssistant, - mock_connect_zigpy_app, + mock_create_zigpy_app, ) -> None: """Test automatic migration.""" # Set up the config entry @@ -167,7 +168,7 @@ async def test_migrate_matching_port( @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_migrate_matching_port_usb( hass: HomeAssistant, - mock_connect_zigpy_app, + mock_create_zigpy_app, ) -> None: """Test automatic migration.""" # Set up the config entry @@ -214,7 +215,7 @@ async def test_migrate_matching_port_usb( async def test_migrate_matching_port_config_entry_not_loaded( hass: HomeAssistant, - mock_connect_zigpy_app, + mock_create_zigpy_app, ) -> None: """Test automatic migration.""" # Set up the config entry @@ -268,13 +269,13 @@ async def test_migrate_matching_port_config_entry_not_loaded( @patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager.async_restore_backup_step_1", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", side_effect=OSError, ) async def test_migrate_matching_port_retry( mock_restore_backup_step_1, hass: HomeAssistant, - mock_connect_zigpy_app, + mock_create_zigpy_app, ) -> None: """Test automatic migration.""" # Set up the config entry @@ -331,7 +332,7 @@ async def test_migrate_matching_port_retry( async def test_migrate_non_matching_port( hass: HomeAssistant, - mock_connect_zigpy_app, + mock_create_zigpy_app, ) -> None: """Test automatic migration.""" # Set up the config entry @@ -379,7 +380,7 @@ async def test_migrate_non_matching_port( async def test_migrate_initiate_failure( hass: HomeAssistant, - mock_connect_zigpy_app, + mock_create_zigpy_app, ) -> None: """Test retries with failure.""" # Set up the config entry @@ -416,7 +417,7 @@ async def test_migrate_initiate_failure( } mock_load_info = AsyncMock(side_effect=OSError()) - mock_connect_zigpy_app.__aenter__.return_value.load_network_info = mock_load_info + mock_create_zigpy_app.__aenter__.return_value.load_network_info = mock_load_info migration_helper = radio_manager.ZhaMultiPANMigrationHelper(hass, config_entry) @@ -484,3 +485,32 @@ async def test_detect_radio_type_failure_no_detect( ): assert await radio_manager.detect_radio_type() == ProbeResult.PROBING_FAILED assert radio_manager.radio_type is None + + +async def test_load_network_settings_oserror( + radio_manager: ZhaRadioManager, hass: HomeAssistant +) -> None: + """Test that OSError during network settings loading is handled.""" + radio_manager.device_path = "/dev/ttyZigbee" + radio_manager.radio_type = RadioType.ezsp + radio_manager.device_settings = {"database": "/test/db/path"} + + with ( + patch("os.path.exists", side_effect=OSError("Test error")), + pytest.raises(HomeAssistantError, match="Could not read the ZHA database"), + ): + await radio_manager.async_load_network_settings() + + +async def test_create_zigpy_app_connect_oserror( + radio_manager: ZhaRadioManager, hass: HomeAssistant, mock_app +) -> None: + """Test that OSError during zigpy app connection is handled.""" + radio_manager.radio_type = RadioType.ezsp + radio_manager.device_settings = {CONF_DEVICE_PATH: "/dev/ttyZigbee"} + + mock_app.connect.side_effect = OSError("Test error") + + with pytest.raises(HomeAssistantError, match="Failed to connect to Zigbee adapter"): + async with radio_manager.create_zigpy_app(): + pytest.fail("Should not be reached") From 15cc28e6c1edae5d152b6bb348d2ba872cdfcd6c Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 23 Sep 2025 21:03:04 +0100 Subject: [PATCH 078/189] Move first probe firmware to firmware progress in hardware flow (#152819) --- .../firmware_config_flow.py | 195 ++++++++++-------- 1 file changed, 107 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 6df3e697fefe..6ea568890f98 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -88,7 +88,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): self.addon_install_task: asyncio.Task | None = None self.addon_start_task: asyncio.Task | None = None self.addon_uninstall_task: asyncio.Task | None = None - self.firmware_install_task: asyncio.Task | None = None + self.firmware_install_task: asyncio.Task[None] | None = None self.installing_firmware_name: str | None = None def _get_translation_placeholders(self) -> dict[str, str]: @@ -184,91 +184,17 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): step_id: str, next_step_id: str, ) -> ConfigFlowResult: - assert self._device is not None - + """Show progress dialog for installing firmware.""" if not self.firmware_install_task: - # Keep track of the firmware we're working with, for error messages - self.installing_firmware_name = firmware_name - - # Installing new firmware is only truly required if the wrong type is - # installed: upgrading to the latest release of the current firmware type - # isn't strictly necessary for functionality. - firmware_install_required = self._probed_firmware_info is None or ( - self._probed_firmware_info.firmware_type - != expected_installed_firmware_type - ) - - session = async_get_clientsession(self.hass) - client = FirmwareUpdateClient(fw_update_url, session) - - try: - manifest = await client.async_update_data() - fw_manifest = next( - fw for fw in manifest.firmwares if fw.filename.startswith(fw_type) - ) - except (StopIteration, TimeoutError, ClientError, ManifestMissing): - _LOGGER.warning( - "Failed to fetch firmware update manifest", exc_info=True - ) - - # Not having internet access should not prevent setup - if not firmware_install_required: - _LOGGER.debug( - "Skipping firmware upgrade due to index download failure" - ) - return self.async_show_progress_done(next_step_id=next_step_id) - - return self.async_show_progress_done( - next_step_id="firmware_download_failed" - ) - - if not firmware_install_required: - assert self._probed_firmware_info is not None - - # Make sure we do not downgrade the firmware - fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata) - fw_version = fw_metadata.get_public_version() - probed_fw_version = Version(self._probed_firmware_info.firmware_version) - - if probed_fw_version >= fw_version: - _LOGGER.debug( - "Not downgrading firmware, installed %s is newer than available %s", - probed_fw_version, - fw_version, - ) - return self.async_show_progress_done(next_step_id=next_step_id) - - try: - fw_data = await client.async_fetch_firmware(fw_manifest) - except (TimeoutError, ClientError, ValueError): - _LOGGER.warning("Failed to fetch firmware update", exc_info=True) - - # If we cannot download new firmware, we shouldn't block setup - if not firmware_install_required: - _LOGGER.debug( - "Skipping firmware upgrade due to image download failure" - ) - return self.async_show_progress_done(next_step_id=next_step_id) - - # Otherwise, fail - return self.async_show_progress_done( - next_step_id="firmware_download_failed" - ) - self.firmware_install_task = self.hass.async_create_task( - async_flash_silabs_firmware( - hass=self.hass, - device=self._device, - fw_data=fw_data, - expected_installed_firmware_type=expected_installed_firmware_type, - bootloader_reset_type=None, - progress_callback=lambda offset, total: self.async_update_progress( - offset / total - ), + self._install_firmware( + fw_update_url, + fw_type, + firmware_name, + expected_installed_firmware_type, ), - f"Flash {firmware_name} firmware", + f"Install {firmware_name} firmware", ) - if not self.firmware_install_task.done(): return self.async_show_progress( step_id=step_id, @@ -282,12 +208,102 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): try: await self.firmware_install_task + except AbortFlow as err: + return self.async_show_progress_done( + next_step_id=err.reason, + ) except HomeAssistantError: _LOGGER.exception("Failed to flash firmware") return self.async_show_progress_done(next_step_id="firmware_install_failed") + finally: + self.firmware_install_task = None return self.async_show_progress_done(next_step_id=next_step_id) + async def _install_firmware( + self, + fw_update_url: str, + fw_type: str, + firmware_name: str, + expected_installed_firmware_type: ApplicationType, + ) -> None: + """Install firmware.""" + if not await self._probe_firmware_info(): + raise AbortFlow( + reason="unsupported_firmware", + description_placeholders=self._get_translation_placeholders(), + ) + + assert self._device is not None + + # Keep track of the firmware we're working with, for error messages + self.installing_firmware_name = firmware_name + + # Installing new firmware is only truly required if the wrong type is + # installed: upgrading to the latest release of the current firmware type + # isn't strictly necessary for functionality. + firmware_install_required = self._probed_firmware_info is None or ( + self._probed_firmware_info.firmware_type != expected_installed_firmware_type + ) + + session = async_get_clientsession(self.hass) + client = FirmwareUpdateClient(fw_update_url, session) + + try: + manifest = await client.async_update_data() + fw_manifest = next( + fw for fw in manifest.firmwares if fw.filename.startswith(fw_type) + ) + except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err: + _LOGGER.warning("Failed to fetch firmware update manifest", exc_info=True) + + # Not having internet access should not prevent setup + if not firmware_install_required: + _LOGGER.debug("Skipping firmware upgrade due to index download failure") + return + + raise AbortFlow(reason="firmware_download_failed") from err + + if not firmware_install_required: + assert self._probed_firmware_info is not None + + # Make sure we do not downgrade the firmware + fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata) + fw_version = fw_metadata.get_public_version() + probed_fw_version = Version(self._probed_firmware_info.firmware_version) + + if probed_fw_version >= fw_version: + _LOGGER.debug( + "Not downgrading firmware, installed %s is newer than available %s", + probed_fw_version, + fw_version, + ) + return + + try: + fw_data = await client.async_fetch_firmware(fw_manifest) + except (TimeoutError, ClientError, ValueError) as err: + _LOGGER.warning("Failed to fetch firmware update", exc_info=True) + + # If we cannot download new firmware, we shouldn't block setup + if not firmware_install_required: + _LOGGER.debug("Skipping firmware upgrade due to image download failure") + return + + # Otherwise, fail + raise AbortFlow(reason="firmware_download_failed") from err + + await async_flash_silabs_firmware( + hass=self.hass, + device=self._device, + fw_data=fw_data, + expected_installed_firmware_type=expected_installed_firmware_type, + bootloader_reset_type=None, + progress_callback=lambda offset, total: self.async_update_progress( + offset / total + ), + ) + async def _configure_and_start_otbr_addon(self) -> None: """Configure and start the OTBR addon.""" @@ -353,6 +369,15 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): }, ) + async def async_step_unsupported_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Abort when unsupported firmware is detected.""" + return self.async_abort( + reason="unsupported_firmware", + description_placeholders=self._get_translation_placeholders(), + ) + async def async_step_zigbee_installation_type( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -406,12 +431,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): async def _async_continue_picked_firmware(self) -> ConfigFlowResult: """Continue to the picked firmware step.""" - if not await self._probe_firmware_info(): - return self.async_abort( - reason="unsupported_firmware", - description_placeholders=self._get_translation_placeholders(), - ) - if self._picked_firmware_type == PickedFirmwareType.ZIGBEE: return await self.async_step_install_zigbee_firmware() From 20293e2a114d9443424c4bcb17bc7f757b105a90 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 23 Sep 2025 22:05:38 +0200 Subject: [PATCH 079/189] Bump aiohasupervisor to 0.3.3b0 (#152835) Co-authored-by: Claude --- homeassistant/components/hassio/manifest.json | 2 +- homeassistant/components/hassio/strings.json | 4 +++ homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../backup_done_with_addon_folder_errors.json | 27 ++++++++++++------- tests/components/hassio/test_backup.py | 3 +++ 9 files changed, 31 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index 197ca8d67f84..cf78eaea05d1 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/hassio", "iot_class": "local_polling", "quality_scale": "internal", - "requirements": ["aiohasupervisor==0.3.2"], + "requirements": ["aiohasupervisor==0.3.3b0"], "single_config_entry": true } diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 94c40732f4d1..d93fff8d06d6 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -250,6 +250,10 @@ "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. For troubleshooting information, select Learn more." + }, + "unsupported_home_assistant_core_version": { + "title": "Unsupported system - Home Assistant Core version", + "description": "System is unsupported because the Home Assistant Core version in use is not supported. For troubleshooting information, select Learn more." } }, "entity": { diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b6c5e88984d5..facfb507fb5a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,7 +3,7 @@ aiodhcpwatcher==1.2.1 aiodiscover==2.7.1 aiodns==3.5.0 -aiohasupervisor==0.3.2 +aiohasupervisor==0.3.3b0 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 aiohttp==3.12.15 diff --git a/pyproject.toml b/pyproject.toml index c81dd7e00f3c..366482ec7fc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 - "aiohasupervisor==0.3.2", + "aiohasupervisor==0.3.3b0", "aiohttp==3.12.15", "aiohttp_cors==0.8.1", "aiohttp-fast-zlib==0.3.0", diff --git a/requirements.txt b/requirements.txt index 8ba1d7be7363..0f161b69c202 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ # Home Assistant Core aiodns==3.5.0 -aiohasupervisor==0.3.2 +aiohasupervisor==0.3.3b0 aiohttp==3.12.15 aiohttp_cors==0.8.1 aiohttp-fast-zlib==0.3.0 diff --git a/requirements_all.txt b/requirements_all.txt index 83da8573a51a..3ed99db0740f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -265,7 +265,7 @@ aioguardian==2022.07.0 aioharmony==0.5.3 # homeassistant.components.hassio -aiohasupervisor==0.3.2 +aiohasupervisor==0.3.3b0 # homeassistant.components.home_connect aiohomeconnect==0.19.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 698e558de6ed..1125d5db7d1e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -250,7 +250,7 @@ aioguardian==2022.07.0 aioharmony==0.5.3 # homeassistant.components.hassio -aiohasupervisor==0.3.2 +aiohasupervisor==0.3.3b0 # homeassistant.components.home_connect aiohomeconnect==0.19.0 diff --git a/tests/components/hassio/fixtures/backup_done_with_addon_folder_errors.json b/tests/components/hassio/fixtures/backup_done_with_addon_folder_errors.json index 183a38a60db3..e13bf364e9a3 100644 --- a/tests/components/hassio/fixtures/backup_done_with_addon_folder_errors.json +++ b/tests/components/hassio/fixtures/backup_done_with_addon_folder_errors.json @@ -19,7 +19,8 @@ "done": true, "errors": [], "created": "2025-05-14T08:56:22.807078+00:00", - "child_jobs": [] + "child_jobs": [], + "extra": null }, { "name": "backup_store_addons", @@ -57,7 +58,8 @@ } ], "created": "2025-05-14T08:56:22.844160+00:00", - "child_jobs": [] + "child_jobs": [], + "extra": null }, { "name": "backup_addon_save", @@ -74,9 +76,11 @@ } ], "created": "2025-05-14T08:56:22.850376+00:00", - "child_jobs": [] + "child_jobs": [], + "extra": null } - ] + ], + "extra": null }, { "name": "backup_store_folders", @@ -119,7 +123,8 @@ } ], "created": "2025-05-14T08:56:22.858385+00:00", - "child_jobs": [] + "child_jobs": [], + "extra": null }, { "name": "backup_folder_save", @@ -136,7 +141,8 @@ } ], "created": "2025-05-14T08:56:22.859973+00:00", - "child_jobs": [] + "child_jobs": [], + "extra": null }, { "name": "backup_folder_save", @@ -153,10 +159,13 @@ } ], "created": "2025-05-14T08:56:22.860792+00:00", - "child_jobs": [] + "child_jobs": [], + "extra": null } - ] + ], + "extra": null } - ] + ], + "extra": null } } diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index fb791b38fc55..0d9b0defe831 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -268,6 +268,7 @@ TEST_JOB_NOT_DONE = supervisor_jobs.Job( errors=[], created=datetime.fromisoformat("1970-01-01T00:00:00Z"), child_jobs=[], + extra=None, ) TEST_JOB_DONE = supervisor_jobs.Job( name="backup_manager_partial_backup", @@ -279,6 +280,7 @@ TEST_JOB_DONE = supervisor_jobs.Job( errors=[], created=datetime.fromisoformat("1970-01-01T00:00:00Z"), child_jobs=[], + extra=None, ) TEST_RESTORE_JOB_DONE_WITH_ERROR = supervisor_jobs.Job( name="backup_manager_partial_restore", @@ -299,6 +301,7 @@ TEST_RESTORE_JOB_DONE_WITH_ERROR = supervisor_jobs.Job( ], created=datetime.fromisoformat("1970-01-01T00:00:00Z"), child_jobs=[], + extra=None, ) From 3bac6b86dfdc51e0b062f9b0b941290978afdb24 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Tue, 23 Sep 2025 22:06:06 +0200 Subject: [PATCH 080/189] Fix multiple_here_travel_time_entries issue description (#152839) --- homeassistant/components/here_travel_time/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/here_travel_time/strings.json b/homeassistant/components/here_travel_time/strings.json index 95fd77d5fa98..ec457bf7099d 100644 --- a/homeassistant/components/here_travel_time/strings.json +++ b/homeassistant/components/here_travel_time/strings.json @@ -111,7 +111,7 @@ "issues": { "multiple_here_travel_time_entries": { "title": "More than one HERE Travel Time integration detected", - "description": "HERE deprecated the previous free tier. You have change to the Base Plan which has 5000 instead of 30000 free requests per month.\n\nSince you have more than one HERE Travel Time integration configured, you will need to disable or remove the additional integrations to avoid exceeding the free request limit.\nYou can ignore this issue if you are okay with the additional cost." + "description": "HERE deprecated the previous free tier. The new Base Plan has only 5000 instead of the previous 30000 free requests per month.\n\nSince you have more than one HERE Travel Time integration configured, you will need to disable or remove the additional integrations to avoid exceeding the free request limit.\nYou can ignore this issue if you are okay with the additional cost." } } } From 3bc2ea7b5f5da2cf606bd9e168c1e690916c627d Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 23 Sep 2025 22:07:24 +0200 Subject: [PATCH 081/189] Use DOMAIN not MODBUS_DOMAIN (#152823) --- homeassistant/components/modbus/__init__.py | 2 +- homeassistant/components/modbus/const.py | 1 + homeassistant/components/modbus/modbus.py | 2 +- homeassistant/components/modbus/validators.py | 2 +- tests/components/modbus/conftest.py | 2 +- tests/components/modbus/test_binary_sensor.py | 4 ++-- tests/components/modbus/test_climate.py | 4 ++-- tests/components/modbus/test_cover.py | 4 ++-- tests/components/modbus/test_fan.py | 6 +++--- tests/components/modbus/test_init.py | 2 +- tests/components/modbus/test_light.py | 6 +++--- tests/components/modbus/test_sensor.py | 4 ++-- tests/components/modbus/test_switch.py | 6 +++--- 13 files changed, 23 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index d933eed82cd2..1847c4fb738c 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -148,7 +148,7 @@ from .const import ( DEFAULT_HVAC_ON_VALUE, DEFAULT_SCAN_INTERVAL, DEFAULT_TEMP_UNIT, - MODBUS_DOMAIN as DOMAIN, + DOMAIN, RTUOVERTCP, SERIAL, TCP, diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index dafc604e7812..9eab4299b18d 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -159,6 +159,7 @@ DEFAULT_TEMP_UNIT = "C" DEFAULT_HVAC_ON_VALUE = 1 DEFAULT_HVAC_OFF_VALUE = 0 MODBUS_DOMAIN = "modbus" +DOMAIN = "modbus" ACTIVE_SCAN_INTERVAL = 2 # limit to force an extra update diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 26992404e38f..89cdb7d47e44 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -56,7 +56,7 @@ from .const import ( CONF_STOPBITS, DEFAULT_HUB, DEVICE_ID, - MODBUS_DOMAIN as DOMAIN, + DOMAIN, PLATFORMS, RTUOVERTCP, SERIAL, diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index f8f1a7450eba..fba0736c64dd 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -36,7 +36,7 @@ from .const import ( CONF_VIRTUAL_COUNT, DEFAULT_HUB, DEFAULT_SCAN_INTERVAL, - MODBUS_DOMAIN as DOMAIN, + DOMAIN, PLATFORMS, SERIAL, DataType, diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index f7bd4b13a1b7..a57c2cfdcc58 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -11,7 +11,7 @@ from freezegun.api import FrozenDateTimeFactory from pymodbus.exceptions import ModbusException import pytest -from homeassistant.components.modbus.const import MODBUS_DOMAIN as DOMAIN, TCP +from homeassistant.components.modbus.const import DOMAIN, TCP from homeassistant.const import ( CONF_ADDRESS, CONF_HOST, diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index 758b1fd7a7ac..a8acb5f4674b 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.modbus.const import ( CONF_INPUT_TYPE, CONF_SLAVE_COUNT, CONF_VIRTUAL_COUNT, - MODBUS_DOMAIN, + DOMAIN, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -439,7 +439,7 @@ async def test_no_discovery_info_binary_sensor( assert await async_setup_component( hass, SENSOR_DOMAIN, - {SENSOR_DOMAIN: {CONF_PLATFORM: MODBUS_DOMAIN}}, + {SENSOR_DOMAIN: {CONF_PLATFORM: DOMAIN}}, ) await hass.async_block_till_done() assert SENSOR_DOMAIN in hass.config.components diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 409d864949c8..14bc46042f69 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -84,7 +84,7 @@ from homeassistant.components.modbus.const import ( CONF_TARGET_TEMP, CONF_TARGET_TEMP_WRITE_REGISTERS, CONF_WRITE_REGISTERS, - MODBUS_DOMAIN, + DOMAIN, DataType, ) from homeassistant.const import ( @@ -1695,7 +1695,7 @@ async def test_no_discovery_info_climate( assert await async_setup_component( hass, CLIMATE_DOMAIN, - {CLIMATE_DOMAIN: {CONF_PLATFORM: MODBUS_DOMAIN}}, + {CLIMATE_DOMAIN: {CONF_PLATFORM: DOMAIN}}, ) await hass.async_block_till_done() assert CLIMATE_DOMAIN in hass.config.components diff --git a/tests/components/modbus/test_cover.py b/tests/components/modbus/test_cover.py index a244ce80399a..9f3a64c27e59 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -16,7 +16,7 @@ from homeassistant.components.modbus.const import ( CONF_STATE_OPENING, CONF_STATUS_REGISTER, CONF_STATUS_REGISTER_TYPE, - MODBUS_DOMAIN, + DOMAIN, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -305,7 +305,7 @@ async def test_no_discovery_info_cover( assert await async_setup_component( hass, COVER_DOMAIN, - {COVER_DOMAIN: {CONF_PLATFORM: MODBUS_DOMAIN}}, + {COVER_DOMAIN: {CONF_PLATFORM: DOMAIN}}, ) await hass.async_block_till_done() assert COVER_DOMAIN in hass.config.components diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index 2afc6314048b..c9796bbaf3c1 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -17,7 +17,7 @@ from homeassistant.components.modbus.const import ( CONF_STATE_ON, CONF_VERIFY, CONF_WRITE_TYPE, - MODBUS_DOMAIN, + DOMAIN, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -270,7 +270,7 @@ async def test_fan_service_turn( ) -> None: """Run test for service turn_on/turn_off.""" - assert MODBUS_DOMAIN in hass.config.components + assert DOMAIN in hass.config.components assert hass.states.get(ENTITY_ID).state == STATE_OFF await hass.services.async_call( @@ -354,7 +354,7 @@ async def test_no_discovery_info_fan( assert await async_setup_component( hass, FAN_DOMAIN, - {FAN_DOMAIN: {CONF_PLATFORM: MODBUS_DOMAIN}}, + {FAN_DOMAIN: {CONF_PLATFORM: DOMAIN}}, ) await hass.async_block_till_done() assert FAN_DOMAIN in hass.config.components diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index aa0ef1dcca7f..a0c38e37ce58 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -64,7 +64,7 @@ from homeassistant.components.modbus.const import ( CONF_VIRTUAL_COUNT, DEFAULT_SCAN_INTERVAL, DEVICE_ID, - MODBUS_DOMAIN as DOMAIN, + DOMAIN, RTUOVERTCP, SERIAL, SERVICE_STOP, diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index 56b6d0ef3b41..9b8eed7437f8 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -22,7 +22,7 @@ from homeassistant.components.modbus.const import ( CONF_STATE_ON, CONF_VERIFY, CONF_WRITE_TYPE, - MODBUS_DOMAIN, + DOMAIN, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -311,7 +311,7 @@ async def test_light_service_turn( ) -> None: """Run test for service turn_on/turn_off.""" - assert MODBUS_DOMAIN in hass.config.components + assert DOMAIN in hass.config.components assert hass.states.get(ENTITY_ID).state == STATE_OFF await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, service_data={ATTR_ENTITY_ID: ENTITY_ID} @@ -535,7 +535,7 @@ async def test_no_discovery_info_light( assert await async_setup_component( hass, LIGHT_DOMAIN, - {LIGHT_DOMAIN: {CONF_PLATFORM: MODBUS_DOMAIN}}, + {LIGHT_DOMAIN: {CONF_PLATFORM: DOMAIN}}, ) await hass.async_block_till_done() assert LIGHT_DOMAIN in hass.config.components diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 868e8a8baada..ef9c6b5b8cd4 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -23,7 +23,7 @@ from homeassistant.components.modbus.const import ( CONF_SWAP_WORD_BYTE, CONF_VIRTUAL_COUNT, CONF_ZERO_SUPPRESS, - MODBUS_DOMAIN, + DOMAIN, DataType, ) from homeassistant.components.sensor import ( @@ -1482,7 +1482,7 @@ async def test_no_discovery_info_sensor( assert await async_setup_component( hass, SENSOR_DOMAIN, - {SENSOR_DOMAIN: {CONF_PLATFORM: MODBUS_DOMAIN}}, + {SENSOR_DOMAIN: {CONF_PLATFORM: DOMAIN}}, ) await hass.async_block_till_done() assert SENSOR_DOMAIN in hass.config.components diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index fc994c70d498..f9763e80307a 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -19,7 +19,7 @@ from homeassistant.components.modbus.const import ( CONF_STATE_ON, CONF_VERIFY, CONF_WRITE_TYPE, - MODBUS_DOMAIN, + DOMAIN, ) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( @@ -349,7 +349,7 @@ async def test_switch_service_turn( mock_modbus, ) -> None: """Run test for service turn_on/turn_off.""" - assert MODBUS_DOMAIN in hass.config.components + assert DOMAIN in hass.config.components assert hass.states.get(ENTITY_ID).state == STATE_OFF await hass.services.async_call( @@ -520,7 +520,7 @@ async def test_no_discovery_info_switch( assert await async_setup_component( hass, SWITCH_DOMAIN, - {SWITCH_DOMAIN: {CONF_PLATFORM: MODBUS_DOMAIN}}, + {SWITCH_DOMAIN: {CONF_PLATFORM: DOMAIN}}, ) await hass.async_block_till_done() assert SWITCH_DOMAIN in hass.config.components From 60bf298ca6df7363cdfd0b7f79e97d0985ca5249 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Tue, 23 Sep 2025 16:25:56 -0400 Subject: [PATCH 082/189] File add read_file action with Response (#139216) --- .../components/default_config/manifest.json | 1 + homeassistant/components/file/__init__.py | 11 ++ homeassistant/components/file/const.py | 4 + homeassistant/components/file/icons.json | 7 + homeassistant/components/file/services.py | 88 +++++++++++ homeassistant/components/file/services.yaml | 14 ++ homeassistant/components/file/strings.json | 31 ++++ homeassistant/package_constraints.txt | 1 + tests/components/file/conftest.py | 13 ++ tests/components/file/fixtures/file_read.json | 1 + .../file/fixtures/file_read.not_json | 1 + .../file/fixtures/file_read.not_yaml | 4 + tests/components/file/fixtures/file_read.yaml | 5 + .../file/fixtures/file_read_list.yaml | 4 + .../file/snapshots/test_services.ambr | 39 +++++ tests/components/file/test_services.py | 147 ++++++++++++++++++ 16 files changed, 371 insertions(+) create mode 100644 homeassistant/components/file/icons.json create mode 100644 homeassistant/components/file/services.py create mode 100644 homeassistant/components/file/services.yaml create mode 100644 tests/components/file/fixtures/file_read.json create mode 100644 tests/components/file/fixtures/file_read.not_json create mode 100644 tests/components/file/fixtures/file_read.not_yaml create mode 100644 tests/components/file/fixtures/file_read.yaml create mode 100644 tests/components/file/fixtures/file_read_list.yaml create mode 100644 tests/components/file/snapshots/test_services.ambr create mode 100644 tests/components/file/test_services.py diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index 3d845066251e..7aa037ac0478 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -9,6 +9,7 @@ "conversation", "dhcp", "energy", + "file", "go2rtc", "history", "homeassistant_alerts", diff --git a/homeassistant/components/file/__init__.py b/homeassistant/components/file/__init__.py index 59a08715b8e4..8f49fb097756 100644 --- a/homeassistant/components/file/__init__.py +++ b/homeassistant/components/file/__init__.py @@ -7,11 +7,22 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_FILE_PATH, CONF_NAME, CONF_PLATFORM, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN +from .services import async_register_services PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the file component.""" + async_register_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a file component entry.""" diff --git a/homeassistant/components/file/const.py b/homeassistant/components/file/const.py index 0fa9f8a421bd..2504610bf5a2 100644 --- a/homeassistant/components/file/const.py +++ b/homeassistant/components/file/const.py @@ -6,3 +6,7 @@ CONF_TIMESTAMP = "timestamp" DEFAULT_NAME = "File" FILE_ICON = "mdi:file" + +SERVICE_READ_FILE = "read_file" +ATTR_FILE_NAME = "file_name" +ATTR_FILE_ENCODING = "file_encoding" diff --git a/homeassistant/components/file/icons.json b/homeassistant/components/file/icons.json new file mode 100644 index 000000000000..826048974cc4 --- /dev/null +++ b/homeassistant/components/file/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "read_file": { + "service": "mdi:file" + } + } +} diff --git a/homeassistant/components/file/services.py b/homeassistant/components/file/services.py new file mode 100644 index 000000000000..3db7bb2c922d --- /dev/null +++ b/homeassistant/components/file/services.py @@ -0,0 +1,88 @@ +"""File Service calls.""" + +from collections.abc import Callable +import json + +import voluptuous as vol +import yaml + +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv + +from .const import ATTR_FILE_ENCODING, ATTR_FILE_NAME, DOMAIN, SERVICE_READ_FILE + + +def async_register_services(hass: HomeAssistant) -> None: + """Register services for File integration.""" + + if not hass.services.has_service(DOMAIN, SERVICE_READ_FILE): + hass.services.async_register( + DOMAIN, + SERVICE_READ_FILE, + read_file, + schema=vol.Schema( + { + vol.Required(ATTR_FILE_NAME): cv.string, + vol.Required(ATTR_FILE_ENCODING): cv.string, + } + ), + supports_response=SupportsResponse.ONLY, + ) + + +ENCODING_LOADERS: dict[str, tuple[Callable, type[Exception]]] = { + "json": (json.loads, json.JSONDecodeError), + "yaml": (yaml.safe_load, yaml.YAMLError), +} + + +def read_file(call: ServiceCall) -> dict: + """Handle read_file service call.""" + file_name = call.data[ATTR_FILE_NAME] + file_encoding = call.data[ATTR_FILE_ENCODING].lower() + + if not call.hass.config.is_allowed_path(file_name): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_access_to_path", + translation_placeholders={"filename": file_name}, + ) + + if file_encoding not in ENCODING_LOADERS: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unsupported_file_encoding", + translation_placeholders={ + "filename": file_name, + "encoding": file_encoding, + }, + ) + + try: + with open(file_name, encoding="utf-8") as file: + file_content = file.read() + except FileNotFoundError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="file_not_found", + translation_placeholders={"filename": file_name}, + ) from err + except OSError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="file_read_error", + translation_placeholders={"filename": file_name}, + ) from err + + loader, error_type = ENCODING_LOADERS[file_encoding] + try: + data = loader(file_content) + except error_type as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="file_decoding", + translation_placeholders={"filename": file_name, "encoding": file_encoding}, + ) from err + + return {"data": data} diff --git a/homeassistant/components/file/services.yaml b/homeassistant/components/file/services.yaml new file mode 100644 index 000000000000..18dafe88205e --- /dev/null +++ b/homeassistant/components/file/services.yaml @@ -0,0 +1,14 @@ +# Describes the format for available file services +read_file: + fields: + file_name: + example: "www/my_file.json" + selector: + text: + file_encoding: + example: "JSON" + selector: + select: + options: + - "JSON" + - "YAML" diff --git a/homeassistant/components/file/strings.json b/homeassistant/components/file/strings.json index 02f8c42755b6..66666b3dd7d2 100644 --- a/homeassistant/components/file/strings.json +++ b/homeassistant/components/file/strings.json @@ -64,6 +64,37 @@ }, "write_access_failed": { "message": "Write access to {filename} failed: {exc}." + }, + "no_access_to_path": { + "message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" + }, + "unsupported_file_encoding": { + "message": "Cannot read {filename}, unsupported file encoding {encoding}." + }, + "file_decoding": { + "message": "Cannot read file {filename} as {encoding}." + }, + "file_not_found": { + "message": "File {filename} not found." + }, + "file_read_error": { + "message": "Error reading {filename}." + } + }, + "services": { + "read_file": { + "name": "Read file", + "description": "Reads a file and returns the contents.", + "fields": { + "file_name": { + "name": "File name", + "description": "Name of the file to read." + }, + "file_encoding": { + "name": "File encoding", + "description": "Encoding of the file (JSON, YAML.)" + } + } } } } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index facfb507fb5a..227b9e3b9188 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -31,6 +31,7 @@ ciso8601==2.3.3 cronsim==2.6 cryptography==45.0.7 dbus-fast==2.44.3 +file-read-backwards==2.0.0 fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 diff --git a/tests/components/file/conftest.py b/tests/components/file/conftest.py index 5345a0d38d0b..2e167310111c 100644 --- a/tests/components/file/conftest.py +++ b/tests/components/file/conftest.py @@ -5,7 +5,9 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from homeassistant.components.file import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component @pytest.fixture @@ -30,3 +32,14 @@ def mock_is_allowed_path(hass: HomeAssistant, is_allowed: bool) -> Generator[Mag hass.config, "is_allowed_path", return_value=is_allowed ) as allowed_path_mock: yield allowed_path_mock + + +@pytest.fixture +async def setup_ha_file_integration(hass: HomeAssistant): + """Set up Home Assistant and load File integration.""" + await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {}}, + ) + await hass.async_block_till_done() diff --git a/tests/components/file/fixtures/file_read.json b/tests/components/file/fixtures/file_read.json new file mode 100644 index 000000000000..5f7453316207 --- /dev/null +++ b/tests/components/file/fixtures/file_read.json @@ -0,0 +1 @@ +{ "key": "value", "key1": "value1" } diff --git a/tests/components/file/fixtures/file_read.not_json b/tests/components/file/fixtures/file_read.not_json new file mode 100644 index 000000000000..07967a9afa2f --- /dev/null +++ b/tests/components/file/fixtures/file_read.not_json @@ -0,0 +1 @@ +{ "key": "value", "key1": value1 } diff --git a/tests/components/file/fixtures/file_read.not_yaml b/tests/components/file/fixtures/file_read.not_yaml new file mode 100644 index 000000000000..a7e5ad397dc5 --- /dev/null +++ b/tests/components/file/fixtures/file_read.not_yaml @@ -0,0 +1,4 @@ +test: + - element: "X" + - element: "Y" + unexpected: "Z" diff --git a/tests/components/file/fixtures/file_read.yaml b/tests/components/file/fixtures/file_read.yaml new file mode 100644 index 000000000000..cb2a2c9b1f96 --- /dev/null +++ b/tests/components/file/fixtures/file_read.yaml @@ -0,0 +1,5 @@ +mylist: + - name: list_item_1 + id: 1 + - name: list_item_2 + id: 2 diff --git a/tests/components/file/fixtures/file_read_list.yaml b/tests/components/file/fixtures/file_read_list.yaml new file mode 100644 index 000000000000..3e4271b39419 --- /dev/null +++ b/tests/components/file/fixtures/file_read_list.yaml @@ -0,0 +1,4 @@ +- name: list_item_1 + id: 1 +- name: list_item_2 + id: 2 diff --git a/tests/components/file/snapshots/test_services.ambr b/tests/components/file/snapshots/test_services.ambr new file mode 100644 index 000000000000..daa7c3990fae --- /dev/null +++ b/tests/components/file/snapshots/test_services.ambr @@ -0,0 +1,39 @@ +# serializer version: 1 +# name: test_read_file[tests/components/file/fixtures/file_read.json-json] + dict({ + 'data': dict({ + 'key': 'value', + 'key1': 'value1', + }), + }) +# --- +# name: test_read_file[tests/components/file/fixtures/file_read.yaml-yaml] + dict({ + 'data': dict({ + 'mylist': list([ + dict({ + 'id': 1, + 'name': 'list_item_1', + }), + dict({ + 'id': 2, + 'name': 'list_item_2', + }), + ]), + }), + }) +# --- +# name: test_read_file[tests/components/file/fixtures/file_read_list.yaml-yaml] + dict({ + 'data': list([ + dict({ + 'id': 1, + 'name': 'list_item_1', + }), + dict({ + 'id': 2, + 'name': 'list_item_2', + }), + ]), + }) +# --- diff --git a/tests/components/file/test_services.py b/tests/components/file/test_services.py new file mode 100644 index 000000000000..9b7198b9967e --- /dev/null +++ b/tests/components/file/test_services.py @@ -0,0 +1,147 @@ +"""The tests for the notify file platform.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.file import DOMAIN +from homeassistant.components.file.services import ( + ATTR_FILE_ENCODING, + ATTR_FILE_NAME, + SERVICE_READ_FILE, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + + +@pytest.mark.parametrize( + ("file_name", "file_encoding"), + [ + ("tests/components/file/fixtures/file_read.json", "json"), + ("tests/components/file/fixtures/file_read.yaml", "yaml"), + ("tests/components/file/fixtures/file_read_list.yaml", "yaml"), + ], +) +async def test_read_file( + hass: HomeAssistant, + mock_is_allowed_path: MagicMock, + setup_ha_file_integration, + file_name: str, + file_encoding: str, + snapshot: SnapshotAssertion, +) -> None: + """Test reading files in supported formats.""" + result = await hass.services.async_call( + DOMAIN, + SERVICE_READ_FILE, + { + ATTR_FILE_NAME: file_name, + ATTR_FILE_ENCODING: file_encoding, + }, + blocking=True, + return_response=True, + ) + assert result == snapshot + + +async def test_read_file_disallowed_path( + hass: HomeAssistant, + setup_ha_file_integration, +) -> None: + """Test reading in a disallowed path generates error.""" + file_name = "tests/components/file/fixtures/file_read.json" + + with pytest.raises(ServiceValidationError) as sve: + await hass.services.async_call( + DOMAIN, + SERVICE_READ_FILE, + { + ATTR_FILE_NAME: file_name, + ATTR_FILE_ENCODING: "json", + }, + blocking=True, + return_response=True, + ) + assert file_name in str(sve.value) + assert sve.value.translation_key == "no_access_to_path" + assert sve.value.translation_domain == DOMAIN + + +async def test_read_file_bad_encoding_option( + hass: HomeAssistant, + mock_is_allowed_path: MagicMock, + setup_ha_file_integration, +) -> None: + """Test handling error if an invalid encoding is specified.""" + file_name = "tests/components/file/fixtures/file_read.json" + + with pytest.raises(ServiceValidationError) as sve: + await hass.services.async_call( + DOMAIN, + SERVICE_READ_FILE, + { + ATTR_FILE_NAME: file_name, + ATTR_FILE_ENCODING: "invalid", + }, + blocking=True, + return_response=True, + ) + assert file_name in str(sve.value) + assert "invalid" in str(sve.value) + assert sve.value.translation_key == "unsupported_file_encoding" + assert sve.value.translation_domain == DOMAIN + + +@pytest.mark.parametrize( + ("file_name", "file_encoding"), + [ + ("tests/components/file/fixtures/file_read.not_json", "json"), + ("tests/components/file/fixtures/file_read.not_yaml", "yaml"), + ], +) +async def test_read_file_decoding_error( + hass: HomeAssistant, + mock_is_allowed_path: MagicMock, + setup_ha_file_integration, + file_name: str, + file_encoding: str, +) -> None: + """Test decoding errors are handled correctly.""" + with pytest.raises(HomeAssistantError) as hae: + await hass.services.async_call( + DOMAIN, + SERVICE_READ_FILE, + { + ATTR_FILE_NAME: file_name, + ATTR_FILE_ENCODING: file_encoding, + }, + blocking=True, + return_response=True, + ) + assert file_name in str(hae.value) + assert file_encoding in str(hae.value) + assert hae.value.translation_key == "file_decoding" + assert hae.value.translation_domain == DOMAIN + + +async def test_read_file_dne( + hass: HomeAssistant, + mock_is_allowed_path: MagicMock, + setup_ha_file_integration, +) -> None: + """Test handling error if file does not exist.""" + file_name = "tests/components/file/fixtures/file_dne.yaml" + + with pytest.raises(HomeAssistantError) as hae: + _ = await hass.services.async_call( + DOMAIN, + SERVICE_READ_FILE, + { + ATTR_FILE_NAME: file_name, + ATTR_FILE_ENCODING: "yaml", + }, + blocking=True, + return_response=True, + ) + assert file_name in str(hae.value) From 2008a73657a2572f28c275fc5ef6f9906db03ff7 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 23 Sep 2025 22:52:09 +0200 Subject: [PATCH 083/189] Add support for Hue MotionAware sensors (#152811) Co-authored-by: Franck Nijhof --- homeassistant/components/hue/event.py | 31 ++- .../components/hue/v2/binary_sensor.py | 131 +++++++++++- homeassistant/components/hue/v2/device.py | 11 +- homeassistant/components/hue/v2/sensor.py | 69 ++++++- .../components/hue/fixtures/v2_resources.json | 194 ++++++++++++++++++ tests/components/hue/test_binary_sensor.py | 98 ++++++++- tests/components/hue/test_event.py | 4 +- tests/components/hue/test_sensor_v2.py | 50 ++++- 8 files changed, 569 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/hue/event.py b/homeassistant/components/hue/event.py index 4cffbb73a38a..c13cccd48e6e 100644 --- a/homeassistant/components/hue/event.py +++ b/homeassistant/components/hue/event.py @@ -6,6 +6,7 @@ from typing import Any from aiohue.v2 import HueBridgeV2 from aiohue.v2.controllers.events import EventType +from aiohue.v2.models.bell_button import BellButton from aiohue.v2.models.button import Button from aiohue.v2.models.relative_rotary import RelativeRotary, RelativeRotaryDirection @@ -39,19 +40,27 @@ async def async_setup_entry( @callback def async_add_entity( event_type: EventType, - resource: Button | RelativeRotary, + resource: Button | RelativeRotary | BellButton, ) -> None: """Add entity from Hue resource.""" if isinstance(resource, RelativeRotary): async_add_entities( [HueRotaryEventEntity(bridge, api.sensors.relative_rotary, resource)] ) + elif isinstance(resource, BellButton): + async_add_entities( + [HueBellButtonEventEntity(bridge, api.sensors.bell_button, resource)] + ) else: async_add_entities( [HueButtonEventEntity(bridge, api.sensors.button, resource)] ) - for controller in (api.sensors.button, api.sensors.relative_rotary): + for controller in ( + api.sensors.button, + api.sensors.relative_rotary, + api.sensors.bell_button, + ): # add all current items in controller for item in controller: async_add_entity(EventType.RESOURCE_ADDED, item) @@ -67,6 +76,8 @@ async def async_setup_entry( class HueButtonEventEntity(HueBaseEntity, EventEntity): """Representation of a Hue Event entity from a button resource.""" + resource: Button | BellButton + entity_description = EventEntityDescription( key="button", device_class=EventDeviceClass.BUTTON, @@ -91,7 +102,9 @@ class HueButtonEventEntity(HueBaseEntity, EventEntity): } @callback - def _handle_event(self, event_type: EventType, resource: Button) -> None: + def _handle_event( + self, event_type: EventType, resource: Button | BellButton + ) -> None: """Handle status event for this resource (or it's parent).""" if event_type == EventType.RESOURCE_UPDATED and resource.id == self.resource.id: if resource.button is None or resource.button.button_report is None: @@ -102,6 +115,18 @@ class HueButtonEventEntity(HueBaseEntity, EventEntity): super()._handle_event(event_type, resource) +class HueBellButtonEventEntity(HueButtonEventEntity): + """Representation of a Hue Event entity from a bell_button resource.""" + + resource: Button | BellButton + + entity_description = EventEntityDescription( + key="bell_button", + device_class=EventDeviceClass.DOORBELL, + has_entity_name=True, + ) + + class HueRotaryEventEntity(HueBaseEntity, EventEntity): """Representation of a Hue Event entity from a RelativeRotary resource.""" diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py index 17584a0f5cb0..da28fd1f6a94 100644 --- a/homeassistant/components/hue/v2/binary_sensor.py +++ b/homeassistant/components/hue/v2/binary_sensor.py @@ -13,13 +13,18 @@ from aiohue.v2.controllers.events import EventType from aiohue.v2.controllers.sensors import ( CameraMotionController, ContactController, + GroupedMotionController, MotionController, + SecurityAreaMotionController, TamperController, ) from aiohue.v2.models.camera_motion import CameraMotion from aiohue.v2.models.contact import Contact, ContactState from aiohue.v2.models.entertainment_configuration import EntertainmentStatus +from aiohue.v2.models.grouped_motion import GroupedMotion from aiohue.v2.models.motion import Motion +from aiohue.v2.models.resource import ResourceTypes +from aiohue.v2.models.security_area_motion import SecurityAreaMotion from aiohue.v2.models.tamper import Tamper, TamperState from homeassistant.components.binary_sensor import ( @@ -29,21 +34,54 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from ..bridge import HueConfigEntry +from ..bridge import HueBridge, HueConfigEntry +from ..const import DOMAIN from .entity import HueBaseEntity -type SensorType = CameraMotion | Contact | Motion | EntertainmentConfiguration | Tamper +type SensorType = ( + CameraMotion + | Contact + | Motion + | EntertainmentConfiguration + | Tamper + | GroupedMotion + | SecurityAreaMotion +) type ControllerType = ( CameraMotionController | ContactController | MotionController | EntertainmentConfigurationController | TamperController + | GroupedMotionController + | SecurityAreaMotionController ) +def _resource_valid(resource: SensorType, controller: ControllerType) -> bool: + """Return True if the resource is valid.""" + if isinstance(resource, GroupedMotion): + # filter out GroupedMotion sensors that are not linked to a valid group/parent + if resource.owner.rtype not in ( + ResourceTypes.ROOM, + ResourceTypes.ZONE, + ResourceTypes.SERVICE_GROUP, + ): + return False + # guard against GroupedMotion without parent (should not happen, but just in case) + if not (parent := controller.get_parent(resource.id)): + return False + # filter out GroupedMotion sensors that have only one member, because Hue creates one + # default grouped Motion sensor per zone/room, which is not useful to expose in HA + if len(parent.children) <= 1: + return False + # default/other checks can go here (none for now) + return True + + async def async_setup_entry( hass: HomeAssistant, config_entry: HueConfigEntry, @@ -59,11 +97,17 @@ async def async_setup_entry( @callback def async_add_sensor(event_type: EventType, resource: SensorType) -> None: - """Add Hue Binary Sensor.""" + """Add Hue Binary Sensor from resource added callback.""" + if not _resource_valid(resource, controller): + return async_add_entities([make_binary_sensor_entity(resource)]) # add all current items in controller - async_add_entities(make_binary_sensor_entity(sensor) for sensor in controller) + async_add_entities( + make_binary_sensor_entity(sensor) + for sensor in controller + if _resource_valid(sensor, controller) + ) # register listener for new sensors config_entry.async_on_unload( @@ -78,6 +122,8 @@ async def async_setup_entry( register_items(api.config.entertainment_configuration, HueEntertainmentActiveSensor) register_items(api.sensors.contact, HueContactSensor) register_items(api.sensors.tamper, HueTamperSensor) + register_items(api.sensors.grouped_motion, HueGroupedMotionSensor) + register_items(api.sensors.security_area_motion, HueMotionAwareSensor) # pylint: disable-next=hass-enforce-class-module @@ -102,6 +148,83 @@ class HueMotionSensor(HueBaseEntity, BinarySensorEntity): return self.resource.motion.value +# pylint: disable-next=hass-enforce-class-module +class HueGroupedMotionSensor(HueMotionSensor): + """Representation of a Hue Grouped Motion sensor.""" + + controller: GroupedMotionController + resource: GroupedMotion + + def __init__( + self, + bridge: HueBridge, + controller: GroupedMotionController, + resource: GroupedMotion, + ) -> None: + """Initialize the sensor.""" + super().__init__(bridge, controller, resource) + # link the GroupedMotion sensor to the parent the sensor is associated with + # which can either be a special ServiceGroup or a Zone/Room + parent = self.controller.get_parent(resource.id) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, parent.id)}, + ) + + +# pylint: disable-next=hass-enforce-class-module +class HueMotionAwareSensor(HueMotionSensor): + """Representation of a Motion sensor based on Hue Motion Aware. + + Note that we only create sensors for the SecurityAreaMotion resource + and not for the ConvenienceAreaMotion resource, because the latter + does not have a state when it's not directly controlling lights. + The SecurityAreaMotion resource is always available with a state, allowing + Home Assistant users to actually use it as a motion sensor in their HA automations. + """ + + controller: SecurityAreaMotionController + resource: SecurityAreaMotion + + entity_description = BinarySensorEntityDescription( + key="motion_sensor", + device_class=BinarySensorDeviceClass.MOTION, + has_entity_name=False, + ) + + @property + def name(self) -> str: + """Return sensor name.""" + return self.controller.get_motion_area_configuration(self.resource.id).name + + def __init__( + self, + bridge: HueBridge, + controller: SecurityAreaMotionController, + resource: SecurityAreaMotion, + ) -> None: + """Initialize the sensor.""" + super().__init__(bridge, controller, resource) + # link the MotionAware sensor to the group the sensor is associated with + self._motion_area_configuration = self.controller.get_motion_area_configuration( + resource.id + ) + group_id = self._motion_area_configuration.group.rid + self.group = self.bridge.api.groups[group_id] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.group.id)}, + ) + + async def async_added_to_hass(self) -> None: + """Call when entity is added.""" + await super().async_added_to_hass() + # subscribe to updates of the MotionAreaConfiguration to update the name + self.async_on_remove( + self.bridge.api.config.subscribe( + self._handle_event, self._motion_area_configuration.id + ) + ) + + # pylint: disable-next=hass-enforce-class-module class HueEntertainmentActiveSensor(HueBaseEntity, BinarySensorEntity): """Representation of a Hue Entertainment Configuration as binary sensor.""" diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py index 62dbe9402171..e6bded7a7f7b 100644 --- a/homeassistant/components/hue/v2/device.py +++ b/homeassistant/components/hue/v2/device.py @@ -9,6 +9,7 @@ from aiohue.v2.controllers.events import EventType from aiohue.v2.controllers.groups import Room, Zone from aiohue.v2.models.device import Device from aiohue.v2.models.resource import ResourceTypes +from aiohue.v2.models.service_group import ServiceGroup from homeassistant.const import ( ATTR_CONNECTIONS, @@ -39,16 +40,16 @@ async def async_setup_devices(bridge: HueBridge): dev_controller = api.devices @callback - def add_device(hue_resource: Device | Room | Zone) -> dr.DeviceEntry: + def add_device(hue_resource: Device | Room | Zone | ServiceGroup) -> dr.DeviceEntry: """Register a Hue device in device registry.""" - if isinstance(hue_resource, (Room, Zone)): + if isinstance(hue_resource, (Room, Zone, ServiceGroup)): # Register a Hue Room/Zone as service in HA device registry. return dev_reg.async_get_or_create( config_entry_id=entry.entry_id, entry_type=dr.DeviceEntryType.SERVICE, identifiers={(DOMAIN, hue_resource.id)}, name=hue_resource.metadata.name, - model=hue_resource.type.value.title(), + model=hue_resource.type.value.replace("_", " ").title(), manufacturer=api.config.bridge_device.product_data.manufacturer_name, via_device=(DOMAIN, api.config.bridge_device.id), suggested_area=hue_resource.metadata.name @@ -85,7 +86,7 @@ async def async_setup_devices(bridge: HueBridge): @callback def handle_device_event( - evt_type: EventType, hue_resource: Device | Room | Zone + evt_type: EventType, hue_resource: Device | Room | Zone | ServiceGroup ) -> None: """Handle event from Hue controller.""" if evt_type == EventType.RESOURCE_DELETED: @@ -101,6 +102,7 @@ async def async_setup_devices(bridge: HueBridge): known_devices = [add_device(hue_device) for hue_device in hue_devices] known_devices += [add_device(hue_room) for hue_room in api.groups.room] known_devices += [add_device(hue_zone) for hue_zone in api.groups.zone] + known_devices += [add_device(sg) for sg in api.config.service_group] # Check for nodes that no longer exist and remove them for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id): @@ -111,3 +113,4 @@ async def async_setup_devices(bridge: HueBridge): entry.async_on_unload(dev_controller.subscribe(handle_device_event)) entry.async_on_unload(api.groups.room.subscribe(handle_device_event)) entry.async_on_unload(api.groups.zone.subscribe(handle_device_event)) + entry.async_on_unload(api.config.service_group.subscribe(handle_device_event)) diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py index 1eec4eaa6b9b..0c92b0c8b3ef 100644 --- a/homeassistant/components/hue/v2/sensor.py +++ b/homeassistant/components/hue/v2/sensor.py @@ -9,13 +9,16 @@ from aiohue.v2 import HueBridgeV2 from aiohue.v2.controllers.events import EventType from aiohue.v2.controllers.sensors import ( DevicePowerController, + GroupedLightLevelController, LightLevelController, SensorsController, TemperatureController, ZigbeeConnectivityController, ) from aiohue.v2.models.device_power import DevicePower +from aiohue.v2.models.grouped_light_level import GroupedLightLevel from aiohue.v2.models.light_level import LightLevel +from aiohue.v2.models.resource import ResourceTypes from aiohue.v2.models.temperature import Temperature from aiohue.v2.models.zigbee_connectivity import ZigbeeConnectivity @@ -27,20 +30,50 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import LIGHT_LUX, PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from ..bridge import HueBridge, HueConfigEntry +from ..const import DOMAIN from .entity import HueBaseEntity -type SensorType = DevicePower | LightLevel | Temperature | ZigbeeConnectivity +type SensorType = ( + DevicePower | LightLevel | Temperature | ZigbeeConnectivity | GroupedLightLevel +) type ControllerType = ( DevicePowerController | LightLevelController | TemperatureController | ZigbeeConnectivityController + | GroupedLightLevelController ) +def _resource_valid( + resource: SensorType, controller: ControllerType, api: HueBridgeV2 +) -> bool: + """Return True if the resource is valid.""" + if isinstance(resource, GroupedLightLevel): + # filter out GroupedLightLevel sensors that are not linked to a valid group/parent + if resource.owner.rtype not in ( + ResourceTypes.ROOM, + ResourceTypes.ZONE, + ResourceTypes.SERVICE_GROUP, + ): + return False + # guard against GroupedLightLevel without parent (should not happen, but just in case) + parent_id = resource.owner.rid + parent = api.groups.get(parent_id) or api.config.get(parent_id) + if not parent: + return False + # filter out GroupedLightLevel sensors that have only one member, because Hue creates one + # default grouped LightLevel sensor per zone/room, which is not useful to expose in HA + if len(parent.children) <= 1: + return False + # default/other checks can go here (none for now) + return True + + async def async_setup_entry( hass: HomeAssistant, config_entry: HueConfigEntry, @@ -58,10 +91,16 @@ async def async_setup_entry( @callback def async_add_sensor(event_type: EventType, resource: SensorType) -> None: """Add Hue Sensor.""" + if not _resource_valid(resource, controller, api): + return async_add_entities([make_sensor_entity(resource)]) # add all current items in controller - async_add_entities(make_sensor_entity(sensor) for sensor in controller) + async_add_entities( + make_sensor_entity(sensor) + for sensor in controller + if _resource_valid(sensor, controller, api) + ) # register listener for new sensors config_entry.async_on_unload( @@ -75,6 +114,7 @@ async def async_setup_entry( register_items(ctrl_base.light_level, HueLightLevelSensor) register_items(ctrl_base.device_power, HueBatterySensor) register_items(ctrl_base.zigbee_connectivity, HueZigbeeConnectivitySensor) + register_items(api.sensors.grouped_light_level, HueGroupedLightLevelSensor) # pylint: disable-next=hass-enforce-class-module @@ -140,6 +180,31 @@ class HueLightLevelSensor(HueSensorBase): } +# pylint: disable-next=hass-enforce-class-module +class HueGroupedLightLevelSensor(HueLightLevelSensor): + """Representation of a LightLevel (illuminance) sensor from a Hue GroupedLightLevel resource.""" + + controller: GroupedLightLevelController + resource: GroupedLightLevel + + def __init__( + self, + bridge: HueBridge, + controller: GroupedLightLevelController, + resource: GroupedLightLevel, + ) -> None: + """Initialize the sensor.""" + super().__init__(bridge, controller, resource) + # link the GroupedLightLevel sensor to the parent the sensor is associated with + # which can either be a special ServiceGroup or a Zone/Room + api = self.bridge.api + parent_id = resource.owner.rid + parent = api.groups.get(parent_id) or api.config.get(parent_id) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, parent.id)}, + ) + + # pylint: disable-next=hass-enforce-class-module class HueBatterySensor(HueSensorBase): """Representation of a Hue Battery sensor.""" diff --git a/tests/components/hue/fixtures/v2_resources.json b/tests/components/hue/fixtures/v2_resources.json index 3d718f24c505..321ffa20508e 100644 --- a/tests/components/hue/fixtures/v2_resources.json +++ b/tests/components/hue/fixtures/v2_resources.json @@ -2363,5 +2363,199 @@ "sensitivity_max": 4 }, "type": "motion" + }, + { + "id": "4f317b69-9da0-4b4f-84f2-7ca07b9fe345", + "owner": { + "rid": "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b", + "rtype": "motion_area_configuration" + }, + "enabled": true, + "motion": { + "motion": false, + "motion_valid": true, + "motion_report": { + "changed": "2023-09-23T08:13:42.394Z", + "motion": false + } + }, + "sensitivity": { + "sensitivity": 2, + "sensitivity_max": 4 + }, + "type": "convenience_area_motion" + }, + { + "id": "8b7e4f82-9c3d-4e1a-a5f6-8d9c7b2a3e4f", + "owner": { + "rid": "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b", + "rtype": "motion_area_configuration" + }, + "enabled": true, + "motion": { + "motion": false, + "motion_valid": true, + "motion_report": { + "changed": "2023-09-23T05:54:08.166Z", + "motion": false + } + }, + "sensitivity": { + "sensitivity": 2, + "sensitivity_max": 4 + }, + "type": "security_area_motion" + }, + { + "id": "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b", + "name": "Motion Aware Sensor 1", + "group": { + "rid": "6ddc9066-7e7d-4a03-a773-c73937968296", + "rtype": "room" + }, + "participants": [ + { + "resource": { + "rid": "a17253ed-168d-471a-8e59-01a101441511", + "rtype": "motion_area_candidate" + }, + "status": { + "health": "healthy" + } + } + ], + "services": [ + { + "rid": "4f317b69-9da0-4b4f-84f2-7ca07b9fe345", + "rtype": "convenience_area_motion" + }, + { + "rid": "8b7e4f82-9c3d-4e1a-a5f6-8d9c7b2a3e4f", + "rtype": "security_area_motion" + } + ], + "health": "healthy", + "enabled": true, + "type": "motion_area_configuration" + }, + { + "id": "9f8e7d6c-5b4a-3e2d-1c0b-9a8f7e6d5c4b", + "owner": { + "rid": "3ff06175-29e8-44a8-8fe7-af591b0025da", + "rtype": "device" + }, + "state": "no_update", + "problems": [], + "type": "device_software_update" + }, + { + "id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "owner": { + "rid": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a", + "rtype": "service_group" + }, + "enabled": true, + "light": { + "light_level_report": { + "changed": "2023-09-23T06:19:38.865Z", + "light_level": 0 + } + }, + "type": "grouped_light_level" + }, + { + "id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e", + "owner": { + "rid": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a", + "rtype": "service_group" + }, + "enabled": true, + "motion": { + "motion_report": { + "changed": "2023-09-23T08:20:51.384Z", + "motion": false + } + }, + "type": "grouped_motion" + }, + { + "id": "3c4d5e6f-7a8b-9c0d-1e2f-3a4b5c6d7e8f", + "id_v1": "/sensors/75", + "owner": { + "rid": "3ff06175-29e8-44a8-8fe7-af591b0025da", + "rtype": "device" + }, + "relative_rotary": { + "last_event": { + "action": "start", + "rotation": { + "direction": "clock_wise", + "steps": 30, + "duration": 400 + } + }, + "rotary_report": { + "updated": "2023-09-21T10:00:03.276Z", + "action": "start", + "rotation": { + "direction": "counter_clock_wise", + "steps": 45, + "duration": 400 + } + } + }, + "type": "relative_rotary" + }, + { + "id": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a", + "children": [ + { + "rid": "4f317b69-9da0-4b4f-84f2-7ca07b9fe345", + "rtype": "convenience_area_motion" + }, + { + "rid": "5f317b69-9da0-4b4f-84f2-7ca07b9fe346", + "rtype": "security_area_motion" + } + ], + "services": [ + { + "rid": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e", + "rtype": "grouped_motion" + }, + { + "rid": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "rtype": "grouped_light_level" + } + ], + "metadata": { + "name": "Sensor group" + }, + "type": "service_group" + }, + { + "id": "5e6f7a8b-9c0d-1e2f-3a4b-5c6d7e8f9a0b", + "name": "Test clip resource", + "type": "clip" + }, + { + "id": "6f7a8b9c-0d1e-2f3a-4b5c-6d7e8f9a0b1c", + "type": "matter", + "enabled": true, + "max_fabrics": 5, + "has_qr_code": false + }, + { + "id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d", + "time": { + "time_zone": "UTC", + "time": "2023-09-23T10:30:00Z" + }, + "type": "time" + }, + { + "id": "8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e", + "status": "ready", + "type": "zigbee_device_discovery" } ] diff --git a/tests/components/hue/test_binary_sensor.py b/tests/components/hue/test_binary_sensor.py index b9c21a5231f6..02b4d93acfed 100644 --- a/tests/components/hue/test_binary_sensor.py +++ b/tests/components/hue/test_binary_sensor.py @@ -19,8 +19,7 @@ async def test_binary_sensors( await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR) # there shouldn't have been any requests at this point assert len(mock_bridge_v2.mock_requests) == 0 - # 5 binary_sensors should be created from test data - assert len(hass.states.async_all()) == 5 + # 7 binary_sensors should be created from test data # test motion sensor sensor = hass.states.get("binary_sensor.hue_motion_sensor_motion") @@ -81,6 +80,20 @@ async def test_binary_sensors( assert sensor.name == "Test Camera Motion" assert sensor.attributes["device_class"] == "motion" + # test grouped motion sensor + sensor = hass.states.get("binary_sensor.sensor_group_motion") + assert sensor is not None + assert sensor.state == "off" + assert sensor.name == "Sensor group Motion" + assert sensor.attributes["device_class"] == "motion" + + # test motion aware sensor + sensor = hass.states.get("binary_sensor.motion_aware_sensor_1") + assert sensor is not None + assert sensor.state == "off" + assert sensor.name == "Motion Aware Sensor 1" + assert sensor.attributes["device_class"] == "motion" + async def test_binary_sensor_add_update( hass: HomeAssistant, mock_bridge_v2: Mock @@ -110,3 +123,84 @@ async def test_binary_sensor_add_update( test_entity = hass.states.get(test_entity_id) assert test_entity is not None assert test_entity.state == "on" + + +async def test_grouped_motion_sensor( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test HueGroupedMotionSensor functionality.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR) + + # test grouped motion sensor exists and has correct state + sensor = hass.states.get("binary_sensor.sensor_group_motion") + assert sensor is not None + assert sensor.state == "off" + assert sensor.attributes["device_class"] == "motion" + + # test update of grouped motion sensor works on incoming event + updated_sensor = { + "id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e", + "type": "grouped_motion", + "motion": { + "motion_report": {"changed": "2023-09-23T08:20:51.384Z", "motion": True} + }, + } + mock_bridge_v2.api.emit_event("update", updated_sensor) + await hass.async_block_till_done() + sensor = hass.states.get("binary_sensor.sensor_group_motion") + assert sensor.state == "on" + + # test disabled grouped motion sensor == state unknown + disabled_sensor = { + "id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e", + "type": "grouped_motion", + "enabled": False, + } + mock_bridge_v2.api.emit_event("update", disabled_sensor) + await hass.async_block_till_done() + sensor = hass.states.get("binary_sensor.sensor_group_motion") + assert sensor.state == "unknown" + + +async def test_motion_aware_sensor( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test HueMotionAwareSensor functionality.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR) + + # test motion aware sensor exists and has correct state + sensor = hass.states.get("binary_sensor.motion_aware_sensor_1") + assert sensor is not None + assert sensor.state == "off" + assert sensor.attributes["device_class"] == "motion" + + # test update of motion aware sensor works on incoming event + updated_sensor = { + "id": "8b7e4f82-9c3d-4e1a-a5f6-8d9c7b2a3e4f", + "type": "security_area_motion", + "motion": { + "motion": True, + "motion_valid": True, + "motion_report": {"changed": "2023-09-23T05:54:08.166Z", "motion": True}, + }, + } + mock_bridge_v2.api.emit_event("update", updated_sensor) + await hass.async_block_till_done() + sensor = hass.states.get("binary_sensor.motion_aware_sensor_1") + assert sensor.state == "on" + + # test name update when motion area configuration name changes + updated_config = { + "id": "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b", + "type": "motion_area_configuration", + "name": "Updated Motion Area", + } + mock_bridge_v2.api.emit_event("update", updated_config) + await hass.async_block_till_done() + # The entity name is derived from the motion area configuration name + # but the entity ID doesn't change - we just verify the sensor still exists + sensor = hass.states.get("binary_sensor.motion_aware_sensor_1") + assert sensor is not None + assert sensor.name == "Updated Motion Area" diff --git a/tests/components/hue/test_event.py b/tests/components/hue/test_event.py index 88b441656874..73ae1e5d1d52 100644 --- a/tests/components/hue/test_event.py +++ b/tests/components/hue/test_event.py @@ -17,8 +17,8 @@ async def test_event( """Test event entity for Hue integration.""" await mock_bridge_v2.api.load_test_data(v2_resources_test_data) await setup_platform(hass, mock_bridge_v2, Platform.EVENT) - # 7 entities should be created from test data - assert len(hass.states.async_all()) == 7 + # 8 entities should be created from test data + assert len(hass.states.async_all()) == 8 # pick one of the remote buttons state = hass.states.get("event.hue_dimmer_switch_with_4_controls_button_1") diff --git a/tests/components/hue/test_sensor_v2.py b/tests/components/hue/test_sensor_v2.py index 7c5afae33719..e7b90c2015dc 100644 --- a/tests/components/hue/test_sensor_v2.py +++ b/tests/components/hue/test_sensor_v2.py @@ -27,8 +27,8 @@ async def test_sensors( await setup_platform(hass, mock_bridge_v2, Platform.SENSOR) # there shouldn't have been any requests at this point assert len(mock_bridge_v2.mock_requests) == 0 - # 6 entities should be created from test data - assert len(hass.states.async_all()) == 6 + # 7 entities should be created from test data + assert len(hass.states.async_all()) == 7 # test temperature sensor sensor = hass.states.get("sensor.hue_motion_sensor_temperature") @@ -59,6 +59,16 @@ async def test_sensors( assert sensor.attributes["unit_of_measurement"] == "%" assert sensor.attributes["battery_state"] == "normal" + # test grouped light level sensor + sensor = hass.states.get("sensor.sensor_group_illuminance") + assert sensor is not None + assert sensor.state == "0" + assert sensor.attributes["friendly_name"] == "Sensor group Illuminance" + assert sensor.attributes["device_class"] == "illuminance" + assert sensor.attributes["state_class"] == "measurement" + assert sensor.attributes["unit_of_measurement"] == "lx" + assert sensor.attributes["light_level"] == 0 + # test disabled zigbee_connectivity sensor entity_id = "sensor.wall_switch_with_2_controls_zigbee_connectivity" entity_entry = entity_registry.async_get(entity_id) @@ -139,3 +149,39 @@ async def test_sensor_add_update(hass: HomeAssistant, mock_bridge_v2: Mock) -> N test_entity = hass.states.get(test_entity_id) assert test_entity is not None assert test_entity.state == "22.5" + + +async def test_grouped_light_level_sensor( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test HueGroupedLightLevelSensor functionality.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, Platform.SENSOR) + + # test grouped light level sensor exists and has correct state + sensor = hass.states.get("sensor.sensor_group_illuminance") + assert sensor is not None + assert ( + sensor.state == "0" + ) # Light level 0 translates to 10^((0-1)/10000) ≈ 0 lux (rounded) + assert sensor.attributes["device_class"] == "illuminance" + assert sensor.attributes["light_level"] == 0 + + # test update of grouped light level sensor works on incoming event + updated_sensor = { + "id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "type": "grouped_light_level", + "light": { + "light_level": 30000, + "light_level_report": { + "changed": "2023-09-23T08:20:51.384Z", + "light_level": 30000, + }, + }, + } + mock_bridge_v2.api.emit_event("update", updated_sensor) + await hass.async_block_till_done() + sensor = hass.states.get("sensor.sensor_group_illuminance") + assert ( + sensor.state == "999" + ) # Light level 30000 translates to 10^((30000-1)/10000) ≈ 999 lux From 911f901d9d7f3454e7449229f98070e73e00c85d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 15:54:31 -0500 Subject: [PATCH 084/189] Bump aioesphomeapi to 41.9.0 (#152841) --- 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 269b3874237f..4835ead20494 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==41.6.0", + "aioesphomeapi==41.9.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.3.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 3ed99db0740f..02510fcb97ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.6.0 +aioesphomeapi==41.9.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1125d5db7d1e..f0934cdb36f3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.6.0 +aioesphomeapi==41.9.0 # homeassistant.components.flo aioflo==2021.11.0 From 9ba7dda864185313ab8557f0a33628e1f8e5c8d0 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Sep 2025 22:55:11 +0200 Subject: [PATCH 085/189] Rename logbook integration to "Activity" in user-facing strings (#150950) --- homeassistant/components/logbook/manifest.json | 2 +- homeassistant/components/logbook/strings.json | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/logbook/manifest.json b/homeassistant/components/logbook/manifest.json index b6b68a1489ea..5a84fdb85e58 100644 --- a/homeassistant/components/logbook/manifest.json +++ b/homeassistant/components/logbook/manifest.json @@ -1,6 +1,6 @@ { "domain": "logbook", - "name": "Logbook", + "name": "Activity", "codeowners": ["@home-assistant/core"], "dependencies": ["frontend", "http", "recorder"], "documentation": "https://www.home-assistant.io/integrations/logbook", diff --git a/homeassistant/components/logbook/strings.json b/homeassistant/components/logbook/strings.json index 5a38b57a9b76..8c725a764c67 100644 --- a/homeassistant/components/logbook/strings.json +++ b/homeassistant/components/logbook/strings.json @@ -1,9 +1,9 @@ { - "title": "Logbook", + "title": "Activity", "services": { "log": { "name": "Log", - "description": "Creates a custom entry in the logbook.", + "description": "Tracks a custom activity.", "fields": { "name": { "name": "[%key:common::config_flow::data::name%]", @@ -11,15 +11,15 @@ }, "message": { "name": "Message", - "description": "Message of the logbook entry." + "description": "Message of the activity." }, "entity_id": { "name": "Entity ID", - "description": "Entity to reference in the logbook entry." + "description": "Entity to reference in the activity." }, "domain": { "name": "Domain", - "description": "Determines which icon is used in the logbook entry. The icon illustrates the integration domain related to this logbook entry." + "description": "Determines which icon is used in the activity. The icon illustrates the integration domain related to this activity." } } } From ff47839c614ef36c3d2c5ed8d89cd5b1341cba71 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 23 Sep 2025 22:56:16 +0200 Subject: [PATCH 086/189] Fix support for new Hue bulbs with very wide color temperature support (#152834) --- homeassistant/components/hue/v2/group.py | 6 +++- homeassistant/components/hue/v2/helpers.py | 11 +++--- homeassistant/components/hue/v2/light.py | 40 ++++++++++++++-------- tests/components/hue/test_light_v2.py | 2 +- 4 files changed, 37 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index eb57d99956a6..c9d7bf6408bf 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -162,7 +162,11 @@ class GroupedHueLight(HueBaseEntity, LightEntity): """Turn the grouped_light on.""" transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION)) xy_color = kwargs.get(ATTR_XY_COLOR) - color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP_KELVIN)) + color_temp = normalize_hue_colortemp( + kwargs.get(ATTR_COLOR_TEMP_KELVIN), + color_util.color_temperature_kelvin_to_mired(self.max_color_temp_kelvin), + color_util.color_temperature_kelvin_to_mired(self.min_color_temp_kelvin), + ) brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS)) flash = kwargs.get(ATTR_FLASH) diff --git a/homeassistant/components/hue/v2/helpers.py b/homeassistant/components/hue/v2/helpers.py index 384d2a305960..12c0d6d10e89 100644 --- a/homeassistant/components/hue/v2/helpers.py +++ b/homeassistant/components/hue/v2/helpers.py @@ -23,11 +23,12 @@ def normalize_hue_transition(transition: float | None) -> float | None: return transition -def normalize_hue_colortemp(colortemp_k: int | None) -> int | None: +def normalize_hue_colortemp( + colortemp_k: int | None, min_mireds: int, max_mireds: int +) -> int | None: """Return color temperature within Hue's ranges.""" if colortemp_k is None: return None - colortemp = color_util.color_temperature_kelvin_to_mired(colortemp_k) - # Hue only accepts a range between 153..500 - colortemp = min(colortemp, 500) - return max(colortemp, 153) + colortemp_mireds = color_util.color_temperature_kelvin_to_mired(colortemp_k) + # Hue only accepts a range between min_mireds..max_mireds + return min(max(colortemp_mireds, min_mireds), max_mireds) diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index d83cdaa80093..e22d2c09f43e 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -40,8 +40,8 @@ from .helpers import ( normalize_hue_transition, ) -FALLBACK_MIN_KELVIN = 6500 -FALLBACK_MAX_KELVIN = 2000 +FALLBACK_MIN_MIREDS = 153 # hue default for most lights +FALLBACK_MAX_MIREDS = 500 # hue default for most lights FALLBACK_KELVIN = 5800 # halfway # HA 2025.4 replaced the deprecated effect "None" with HA default "off" @@ -177,25 +177,31 @@ class HueLight(HueBaseEntity, LightEntity): # return a fallback value to prevent issues with mired->kelvin conversions return FALLBACK_KELVIN + @property + def max_color_temp_mireds(self) -> int: + """Return the warmest color_temp in mireds (so highest number) that this light supports.""" + if color_temp := self.resource.color_temperature: + return color_temp.mirek_schema.mirek_maximum + # return a fallback value if the light doesn't provide limits + return FALLBACK_MAX_MIREDS + + @property + def min_color_temp_mireds(self) -> int: + """Return the coldest color_temp in mireds (so lowest number) that this light supports.""" + if color_temp := self.resource.color_temperature: + return color_temp.mirek_schema.mirek_minimum + # return a fallback value if the light doesn't provide limits + return FALLBACK_MIN_MIREDS + @property def max_color_temp_kelvin(self) -> int: """Return the coldest color_temp_kelvin that this light supports.""" - if color_temp := self.resource.color_temperature: - return color_util.color_temperature_mired_to_kelvin( - color_temp.mirek_schema.mirek_minimum - ) - # return a fallback value to prevent issues with mired->kelvin conversions - return FALLBACK_MAX_KELVIN + return color_util.color_temperature_mired_to_kelvin(self.min_color_temp_mireds) @property def min_color_temp_kelvin(self) -> int: """Return the warmest color_temp_kelvin that this light supports.""" - if color_temp := self.resource.color_temperature: - return color_util.color_temperature_mired_to_kelvin( - color_temp.mirek_schema.mirek_maximum - ) - # return a fallback value to prevent issues with mired->kelvin conversions - return FALLBACK_MIN_KELVIN + return color_util.color_temperature_mired_to_kelvin(self.max_color_temp_mireds) @property def extra_state_attributes(self) -> dict[str, str] | None: @@ -220,7 +226,11 @@ class HueLight(HueBaseEntity, LightEntity): """Turn the device on.""" transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION)) xy_color = kwargs.get(ATTR_XY_COLOR) - color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP_KELVIN)) + color_temp = normalize_hue_colortemp( + kwargs.get(ATTR_COLOR_TEMP_KELVIN), + self.min_color_temp_mireds, + self.max_color_temp_mireds, + ) brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS)) if self._last_brightness and brightness is None: # The Hue bridge sets the brightness to 1% when turning on a bulb diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index 13cfe3995de9..a5e7d24c86e8 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -178,7 +178,7 @@ async def test_light_turn_on_service( blocking=True, ) assert len(mock_bridge_v2.mock_requests) == 6 - assert mock_bridge_v2.mock_requests[5]["json"]["color_temperature"]["mirek"] == 500 + assert mock_bridge_v2.mock_requests[5]["json"]["color_temperature"]["mirek"] == 454 # test enable an effect await hass.services.async_call( From a0be737925d65036d5cffbbf63f63f4a8e0ae34b Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 23 Sep 2025 16:19:04 -0500 Subject: [PATCH 087/189] Auto select first active wake word (#152562) --- homeassistant/components/esphome/select.py | 15 +++++++ .../esphome/test_assist_satellite.py | 19 ++++++++- tests/components/esphome/test_select.py | 41 ++++++++++++++++++- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index 4ecde9c51137..65494e06a363 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -194,6 +194,21 @@ class EsphomeAssistSatelliteWakeWordSelect( self._attr_options = [NO_WAKE_WORD, *sorted(self._wake_words)] option = self._attr_current_option + + if ( + (self._wake_word_index == 0) + and (len(config.active_wake_words) == 1) + and (option in (None, NO_WAKE_WORD)) + ): + option = next( + ( + wake_word + for wake_word, wake_word_id in self._wake_words.items() + if wake_word_id == config.active_wake_words[0] + ), + None, + ) + if ( (option is None) or ((wake_word_id := self._wake_words.get(option)) is None) diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 525f56603ad5..d6643c17d456 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -1887,10 +1887,10 @@ async def test_wake_word_select( assert satellite is not None assert satellite.async_get_configuration().active_wake_words == ["hey_jarvis"] - # No wake word should be selected by default + # First wake word should be selected by default state = hass.states.get("select.test_wake_word") assert state is not None - assert state.state == NO_WAKE_WORD + assert state.state == "Hey Jarvis" # Changing the select should set the active wake word await hass.services.async_call( @@ -1955,6 +1955,21 @@ async def test_wake_word_select( # Only primary wake word remains assert satellite.async_get_configuration().active_wake_words == ["okay_nabu"] + # Remove the primary wake word + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.test_wake_word", "option": NO_WAKE_WORD}, + blocking=True, + ) + await hass.async_block_till_done() + + async with asyncio.timeout(1): + await configuration_set.wait() + + # No active wake word remain + assert not satellite.async_get_configuration().active_wake_words + async def test_secondary_pipeline( hass: HomeAssistant, diff --git a/tests/components/esphome/test_select.py b/tests/components/esphome/test_select.py index db41b164c2de..7de4dcd6aca3 100644 --- a/tests/components/esphome/test_select.py +++ b/tests/components/esphome/test_select.py @@ -186,7 +186,7 @@ async def test_wake_word_select_no_active_wake_words( mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: - """Test wake word select uses first available wake word if none are active.""" + """Test wake word select has no wake word selected if none are active.""" device_config = AssistSatelliteConfiguration( available_wake_words=[ AssistSatelliteWakeWord("okay_nabu", "Okay Nabu", ["en"]), @@ -215,3 +215,42 @@ async def test_wake_word_select_no_active_wake_words( state = hass.states.get(entity_id) assert state is not None assert state.state == NO_WAKE_WORD + + +async def test_wake_word_select_first_active_wake_word( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test wake word select uses first available wake word if one is active.""" + device_config = AssistSatelliteConfiguration( + available_wake_words=[ + AssistSatelliteWakeWord("okay_nabu", "Okay Nabu", ["en"]), + AssistSatelliteWakeWord("hey_jarvis", "Hey Jarvis", ["en"]), + ], + active_wake_words=["okay_nabu"], + max_active_wake_words=1, + ) + mock_client.get_voice_assistant_configuration.return_value = device_config + + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.ANNOUNCE + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + # First wake word should be selected + state = hass.states.get("select.test_wake_word") + assert state is not None + assert state.state == "Okay Nabu" + + # Second wake word should not be selected + state_2 = hass.states.get("select.test_wake_word_2") + assert state_2 is not None + assert state_2.state == NO_WAKE_WORD From 14b5b9742cd4aa9667535be700fca9654f6f64df Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 24 Sep 2025 04:15:00 +0200 Subject: [PATCH 088/189] Bump ZHA to 0.0.72 (#152850) --- 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 fd0abef361ac..86763f9c2127 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.71"], + "requirements": ["zha==0.0.72"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 02510fcb97ef..be4868aa7f61 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3222,7 +3222,7 @@ zeroconf==0.147.2 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.71 +zha==0.0.72 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f0934cdb36f3..d83e7a8d1234 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2672,7 +2672,7 @@ zeroconf==0.147.2 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.71 +zha==0.0.72 # homeassistant.components.zwave_js zwave-js-server-python==0.67.1 From dadba274aac62a9813a2d9b7761b1eb48336dd10 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 23 Sep 2025 22:16:32 -0400 Subject: [PATCH 089/189] Bump python-roborock to 2.47.1 (#152844) --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index d89a34d26d66..ef129ab5df5b 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -19,7 +19,7 @@ "loggers": ["roborock"], "quality_scale": "silver", "requirements": [ - "python-roborock==2.44.1", + "python-roborock==2.47.1", "vacuum-map-parser-roborock==0.1.4" ] } diff --git a/requirements_all.txt b/requirements_all.txt index be4868aa7f61..d5bcaf1a1c96 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2528,7 +2528,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.44.1 +python-roborock==2.47.1 # homeassistant.components.smarttub python-smarttub==0.0.44 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d83e7a8d1234..f178f419f344 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2101,7 +2101,7 @@ python-pooldose==0.5.0 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.44.1 +python-roborock==2.47.1 # homeassistant.components.smarttub python-smarttub==0.0.44 From 32aacac55044d4f6ea8db385aa4aa467ab9382ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 23:14:08 -0500 Subject: [PATCH 090/189] Fix async_get_scanner return type for BleakScanner compatibility (#152840) --- homeassistant/components/bluetooth/api.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index f12d22cc8b52..556ae2ac9fd2 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -10,6 +10,7 @@ from asyncio import Future from collections.abc import Callable, Iterable from typing import TYPE_CHECKING, cast +from bleak import BleakScanner from habluetooth import ( BaseHaScanner, BluetoothScannerDevice, @@ -38,13 +39,16 @@ def _get_manager(hass: HomeAssistant) -> HomeAssistantBluetoothManager: @hass_callback -def async_get_scanner(hass: HomeAssistant) -> HaBleakScannerWrapper: - """Return a HaBleakScannerWrapper. +def async_get_scanner(hass: HomeAssistant) -> BleakScanner: + """Return a HaBleakScannerWrapper cast to BleakScanner. This is a wrapper around our BleakScanner singleton that allows multiple integrations to share the same BleakScanner. + + The wrapper is cast to BleakScanner for type compatibility with + libraries expecting a BleakScanner instance. """ - return HaBleakScannerWrapper() + return cast(BleakScanner, HaBleakScannerWrapper()) @hass_callback From ddea2206c3e8ef3dcfd65bc9298437bab97c19a1 Mon Sep 17 00:00:00 2001 From: Nick Kuiper <65495045+NickKoepr@users.noreply.github.com> Date: Wed, 24 Sep 2025 08:11:33 +0200 Subject: [PATCH 091/189] Add start charge session action for blue current integration. (#145446) --- .../components/blue_current/__init__.py | 90 ++++++++++++- .../components/blue_current/const.py | 6 + .../components/blue_current/icons.json | 5 + .../components/blue_current/services.yaml | 12 ++ .../components/blue_current/strings.json | 44 +++++++ tests/components/blue_current/__init__.py | 14 ++- tests/components/blue_current/test_init.py | 119 +++++++++++++++++- 7 files changed, 283 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/blue_current/services.yaml diff --git a/homeassistant/components/blue_current/__init__.py b/homeassistant/components/blue_current/__init__.py index eeda91a70a31..5d0669688737 100644 --- a/homeassistant/components/blue_current/__init__.py +++ b/homeassistant/components/blue_current/__init__.py @@ -13,20 +13,30 @@ from bluecurrent_api.exceptions import ( RequestLimitReached, WebsocketError, ) +import voluptuous as vol -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_TOKEN, Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_API_TOKEN, CONF_DEVICE_ID, Platform +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + ServiceValidationError, +) +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import ConfigType from .const import ( + BCU_APP, CHARGEPOINT_SETTINGS, CHARGEPOINT_STATUS, + CHARGING_CARD_ID, DOMAIN, EVSE_ID, LOGGER, PLUG_AND_CHARGE, + SERVICE_START_CHARGE_SESSION, VALUE, ) @@ -34,6 +44,7 @@ type BlueCurrentConfigEntry = ConfigEntry[Connector] PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] CHARGE_POINTS = "CHARGE_POINTS" +CHARGE_CARDS = "CHARGE_CARDS" DATA = "data" DELAY = 5 @@ -41,6 +52,16 @@ GRID = "GRID" OBJECT = "object" VALUE_TYPES = [CHARGEPOINT_STATUS, CHARGEPOINT_SETTINGS] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + +SERVICE_START_CHARGE_SESSION_SCHEMA = vol.Schema( + { + vol.Required(CONF_DEVICE_ID): cv.string, + # When no charging card is provided, use no charging card (BCU_APP = no charging card). + vol.Optional(CHARGING_CARD_ID, default=BCU_APP): cv.string, + } +) + async def async_setup_entry( hass: HomeAssistant, config_entry: BlueCurrentConfigEntry @@ -67,6 +88,66 @@ async def async_setup_entry( return True +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Blue Current.""" + + async def start_charge_session(service_call: ServiceCall) -> None: + """Start a charge session with the provided device and charge card ID.""" + # When no charge card is provided, use the default charge card set in the config flow. + charging_card_id = service_call.data[CHARGING_CARD_ID] + device_id = service_call.data[CONF_DEVICE_ID] + + # Get the device based on the given device ID. + device = dr.async_get(hass).devices.get(device_id) + + if device is None: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="invalid_device_id" + ) + + blue_current_config_entry: ConfigEntry | None = None + + for config_entry_id in device.config_entries: + config_entry = hass.config_entries.async_get_entry(config_entry_id) + if not config_entry or config_entry.domain != DOMAIN: + # Not the blue_current config entry. + continue + + if config_entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="config_entry_not_loaded" + ) + + blue_current_config_entry = config_entry + break + + if not blue_current_config_entry: + # The device is not connected to a valid blue_current config entry. + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="no_config_entry" + ) + + connector = blue_current_config_entry.runtime_data + + # Get the evse_id from the identifier of the device. + evse_id = next( + identifier[1] + for identifier in device.identifiers + if identifier[0] == DOMAIN + ) + + await connector.client.start_session(evse_id, charging_card_id) + + hass.services.async_register( + DOMAIN, + SERVICE_START_CHARGE_SESSION, + start_charge_session, + SERVICE_START_CHARGE_SESSION_SCHEMA, + ) + + return True + + async def async_unload_entry( hass: HomeAssistant, config_entry: BlueCurrentConfigEntry ) -> bool: @@ -87,6 +168,7 @@ class Connector: self.client = client self.charge_points: dict[str, dict] = {} self.grid: dict[str, Any] = {} + self.charge_cards: dict[str, dict[str, Any]] = {} async def on_data(self, message: dict) -> None: """Handle received data.""" diff --git a/homeassistant/components/blue_current/const.py b/homeassistant/components/blue_current/const.py index 33e0e8b1176a..16b737730b92 100644 --- a/homeassistant/components/blue_current/const.py +++ b/homeassistant/components/blue_current/const.py @@ -8,6 +8,12 @@ LOGGER = logging.getLogger(__package__) EVSE_ID = "evse_id" MODEL_TYPE = "model_type" +CARD = "card" +UID = "uid" +BCU_APP = "BCU-APP" +WITHOUT_CHARGING_CARD = "without_charging_card" +CHARGING_CARD_ID = "charging_card_id" +SERVICE_START_CHARGE_SESSION = "start_charge_session" PLUG_AND_CHARGE = "plug_and_charge" VALUE = "value" PERMISSION = "permission" diff --git a/homeassistant/components/blue_current/icons.json b/homeassistant/components/blue_current/icons.json index 28d4acbc1d80..b8c6a5f045b1 100644 --- a/homeassistant/components/blue_current/icons.json +++ b/homeassistant/components/blue_current/icons.json @@ -42,5 +42,10 @@ "default": "mdi:lock" } } + }, + "services": { + "start_charge_session": { + "service": "mdi:play" + } } } diff --git a/homeassistant/components/blue_current/services.yaml b/homeassistant/components/blue_current/services.yaml new file mode 100644 index 000000000000..70992b5f277b --- /dev/null +++ b/homeassistant/components/blue_current/services.yaml @@ -0,0 +1,12 @@ +start_charge_session: + fields: + device_id: + selector: + device: + integration: blue_current + required: true + + charging_card_id: + selector: + text: + required: false diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index 0a99af603cca..9fdbd756392d 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -22,6 +22,16 @@ "wrong_account": "Wrong account: Please authenticate with the API token for {email}." } }, + "options": { + "step": { + "init": { + "data": { + "card": "Card" + }, + "description": "Select the default charging card you want to use" + } + } + }, "entity": { "sensor": { "activity": { @@ -136,5 +146,39 @@ "name": "Block charge point" } } + }, + "selector": { + "select_charging_card": { + "options": { + "without_charging_card": "Without charging card" + } + } + }, + "services": { + "start_charge_session": { + "name": "Start charge session", + "description": "Starts a new charge session on a specified charge point.", + "fields": { + "charging_card_id": { + "name": "Charging card ID", + "description": "Optional charging card ID that will be used to start a charge session. When not provided, no charging card will be used." + }, + "device_id": { + "name": "Device ID", + "description": "The ID of the Blue Current charge point." + } + } + } + }, + "exceptions": { + "invalid_device_id": { + "message": "Invalid device ID given." + }, + "config_entry_not_loaded": { + "message": "Config entry not loaded." + }, + "no_config_entry": { + "message": "Device has not a valid blue_current config entry." + } } } diff --git a/tests/components/blue_current/__init__.py b/tests/components/blue_current/__init__.py index 402d644747a2..420c3bdfdc5b 100644 --- a/tests/components/blue_current/__init__.py +++ b/tests/components/blue_current/__init__.py @@ -10,7 +10,8 @@ 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.components.blue_current.const import PUBLIC_CHARGING, UID +from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -87,6 +88,16 @@ def create_client_mock( """Send the grid status to the callback.""" await client_mock.receiver({"object": "GRID_STATUS", "data": grid}) + async def get_charge_cards() -> None: + """Send the charge cards list to the callback.""" + await client_mock.receiver( + { + "object": "CHARGE_CARDS", + "default_card": {UID: "BCU-APP", CONF_ID: "BCU-APP"}, + "cards": [{UID: "MOCK-CARD", CONF_ID: "MOCK-CARD", "valid": 1}], + } + ) + async def update_charge_point( evse_id: str, event_object: str, settings: dict[str, Any] ) -> None: @@ -100,6 +111,7 @@ def create_client_mock( 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.get_charge_cards.side_effect = get_charge_cards client_mock.update_charge_point = update_charge_point return client_mock diff --git a/tests/components/blue_current/test_init.py b/tests/components/blue_current/test_init.py index b740e6c91f9a..563a8392dc84 100644 --- a/tests/components/blue_current/test_init.py +++ b/tests/components/blue_current/test_init.py @@ -1,7 +1,7 @@ """Test Blue Current Init Component.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import MagicMock, patch from bluecurrent_api.exceptions import ( BlueCurrentException, @@ -10,15 +10,24 @@ from bluecurrent_api.exceptions import ( WebsocketError, ) import pytest +from voluptuous import MultipleInvalid -from homeassistant.components.blue_current import async_setup_entry +from homeassistant.components.blue_current import ( + CHARGING_CARD_ID, + DOMAIN, + SERVICE_START_CHARGE_SESSION, + async_setup_entry, +) from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_DEVICE_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, IntegrationError, + ServiceValidationError, ) +from homeassistant.helpers.device_registry import DeviceRegistry from . import init_integration @@ -32,6 +41,7 @@ async def test_load_unload_entry( with ( patch("homeassistant.components.blue_current.Client.validate_api_token"), patch("homeassistant.components.blue_current.Client.wait_for_charge_points"), + patch("homeassistant.components.blue_current.Client.get_charge_cards"), patch("homeassistant.components.blue_current.Client.disconnect"), patch( "homeassistant.components.blue_current.Client.connect", @@ -103,3 +113,108 @@ async def test_connect_request_limit_reached_error( await started_loop.wait() assert mock_client.get_next_reset_delta.call_count == 1 assert mock_client.connect.call_count == 2 + + +async def test_start_charging_action( + hass: HomeAssistant, config_entry: MockConfigEntry, device_registry: DeviceRegistry +) -> None: + """Test the start charing action when a charging card is provided.""" + integration = await init_integration(hass, config_entry, Platform.BUTTON) + client = integration[0] + + await hass.services.async_call( + DOMAIN, + SERVICE_START_CHARGE_SESSION, + { + CONF_DEVICE_ID: list(device_registry.devices)[0], + CHARGING_CARD_ID: "TEST_CARD", + }, + blocking=True, + ) + + client.start_session.assert_called_once_with("101", "TEST_CARD") + + +async def test_start_charging_action_without_card( + hass: HomeAssistant, config_entry: MockConfigEntry, device_registry: DeviceRegistry +) -> None: + """Test the start charing action when no charging card is provided.""" + integration = await init_integration(hass, config_entry, Platform.BUTTON) + client = integration[0] + + await hass.services.async_call( + DOMAIN, + SERVICE_START_CHARGE_SESSION, + { + CONF_DEVICE_ID: list(device_registry.devices)[0], + }, + blocking=True, + ) + + client.start_session.assert_called_once_with("101", "BCU-APP") + + +async def test_start_charging_action_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: DeviceRegistry, +) -> None: + """Test the start charing action errors.""" + await init_integration(hass, config_entry, Platform.BUTTON) + + with pytest.raises(MultipleInvalid): + # No device id + await hass.services.async_call( + DOMAIN, + SERVICE_START_CHARGE_SESSION, + {}, + blocking=True, + ) + + with pytest.raises(ServiceValidationError): + # Invalid device id + await hass.services.async_call( + DOMAIN, + SERVICE_START_CHARGE_SESSION, + {CONF_DEVICE_ID: "INVALID"}, + blocking=True, + ) + + # Test when the device is not connected to a valid blue_current config entry. + get_entry_mock = MagicMock() + get_entry_mock.state = ConfigEntryState.LOADED + + with ( + patch.object( + hass.config_entries, "async_get_entry", return_value=get_entry_mock + ), + pytest.raises(ServiceValidationError), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_START_CHARGE_SESSION, + { + CONF_DEVICE_ID: list(device_registry.devices)[0], + }, + blocking=True, + ) + + # Test when the blue_current config entry is not loaded. + get_entry_mock = MagicMock() + get_entry_mock.domain = DOMAIN + get_entry_mock.state = ConfigEntryState.NOT_LOADED + + with ( + patch.object( + hass.config_entries, "async_get_entry", return_value=get_entry_mock + ), + pytest.raises(ServiceValidationError), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_START_CHARGE_SESSION, + { + CONF_DEVICE_ID: list(device_registry.devices)[0], + }, + blocking=True, + ) From ddfc528d634d6a484c1acb6e43b71c7ac0d6d473 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 24 Sep 2025 08:38:32 +0200 Subject: [PATCH 092/189] Fix apparent copy-paste error in tests of trigger helper (#152855) --- tests/helpers/test_trigger.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 7402cf2899f3..d28d0bc1a1c9 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -56,14 +56,10 @@ async def test_trigger_subtype(hass: HomeAssistant) -> None: assert integration_mock.call_args == call(hass, "test") -async def test_trigger_variables(hass: HomeAssistant) -> None: - """Test trigger variables.""" - - -async def test_if_fires_on_event( +async def test_trigger_variables( hass: HomeAssistant, service_calls: list[ServiceCall] ) -> None: - """Test the firing of events.""" + """Test trigger variables.""" assert await async_setup_component( hass, "automation", From 403cd2d8ef70ff9eed0ed16bf0787e4622cbf52a Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 24 Sep 2025 10:24:42 +0200 Subject: [PATCH 093/189] Filter out custom integrations in extended analytics (#152820) --- .../components/analytics/analytics.py | 35 +++++++++---------- tests/components/analytics/test_analytics.py | 22 ------------ 2 files changed, 17 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 3a8f2265044b..b527c8ab9372 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -506,7 +506,7 @@ DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications() DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications() -async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901 +async def async_devices_payload(hass: HomeAssistant) -> dict: """Return detailed information about entities and devices.""" dev_reg = dr.async_get(hass) ent_reg = er.async_get(hass) @@ -538,6 +538,22 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901 integration_input = integration_inputs.setdefault(integration_domain, ([], [])) integration_input[1].append(entity_entry.entity_id) + integrations = { + domain: integration + for domain, integration in ( + await async_get_integrations(hass, integration_inputs.keys()) + ).items() + if isinstance(integration, Integration) + } + + # Filter out custom integrations + integration_inputs = { + domain: integration_info + for domain, integration_info in integration_inputs.items() + if (integration := integrations.get(domain)) is not None + and integration.is_built_in + } + # Call integrations that implement the analytics platform for integration_domain, integration_input in integration_inputs.items(): if ( @@ -688,23 +704,6 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901 else: entities_info.append(entity_info) - integrations = { - domain: integration - for domain, integration in ( - await async_get_integrations(hass, integrations_info.keys()) - ).items() - if isinstance(integration, Integration) - } - - for domain, integration_info in integrations_info.items(): - if integration := integrations.get(domain): - integration_info["is_custom_integration"] = not integration.is_built_in - # Include version for custom integrations - if not integration.is_built_in and integration.version: - integration_info["custom_integration_version"] = str( - integration.version - ) - return { "version": "home-assistant:1", "home_assistant": HA_VERSION, diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index a0bde29979e5..9a63f4b29cba 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -1121,25 +1121,6 @@ async def test_devices_payload_no_entities( }, ], "entities": [], - "is_custom_integration": False, - }, - "test": { - "devices": [ - { - "entities": [], - "entry_type": None, - "has_configuration_url": False, - "hw_version": None, - "manufacturer": "test-manufacturer7", - "model": None, - "model_id": "test-model-id7", - "sw_version": None, - "via_device": None, - }, - ], - "entities": [], - "is_custom_integration": True, - "custom_integration_version": "1.2.3", }, }, } @@ -1299,7 +1280,6 @@ async def test_devices_payload_with_entities( "unit_of_measurement": "°C", }, ], - "is_custom_integration": False, }, "template": { "devices": [], @@ -1315,7 +1295,6 @@ async def test_devices_payload_with_entities( "unit_of_measurement": None, }, ], - "is_custom_integration": False, }, }, } @@ -1429,7 +1408,6 @@ async def test_analytics_platforms( "unit_of_measurement": None, }, ], - "is_custom_integration": False, }, }, } From 8837f2aca7bc3a3a341a990f48064355ff6ba38f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 24 Sep 2025 11:11:35 +0200 Subject: [PATCH 094/189] Capitalize "Auto Cycle Link" as feature name in `smartthings` (#152864) --- homeassistant/components/smartthings/strings.json | 8 ++++---- tests/components/smartthings/snapshots/test_switch.ambr | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index ca4e66d6fd00..0c9cc394fb39 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -141,9 +141,9 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]", - "low": "Low", + "low": "[%key:common::state::low%]", "mid": "Mid", - "high": "High", + "high": "[%key:common::state::high%]", "extra_high": "Extra high" } }, @@ -194,7 +194,7 @@ "state": { "none": "None", "heavy": "Heavy", - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "light": "Light", "extra_light": "Extra light", "extra_heavy": "Extra heavy", @@ -626,7 +626,7 @@ "name": "Power freeze" }, "auto_cycle_link": { - "name": "Auto cycle link" + "name": "Auto Cycle Link" }, "sanitize": { "name": "Sanitize" diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 5797d9e74c54..1bd79b3307c6 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -840,7 +840,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Auto cycle link', + 'original_name': 'Auto Cycle Link', 'platform': 'smartthings', 'previous_unique_id': None, 'suggested_object_id': None, @@ -853,7 +853,7 @@ # name: test_all_entities[da_wm_sc_000001][switch.airdresser_auto_cycle_link-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'AirDresser Auto cycle link', + 'friendly_name': 'AirDresser Auto Cycle Link', }), 'context': , 'entity_id': 'switch.airdresser_auto_cycle_link', From bdd0b74d5109c88f302508e2949a1595e0b1408c Mon Sep 17 00:00:00 2001 From: Patrick Date: Wed, 24 Sep 2025 05:26:22 -0400 Subject: [PATCH 095/189] Enhance Synology DSM handling of external USB drives (#145943) Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> --- .../components/synology_dsm/sensor.py | 16 +++++ tests/components/synology_dsm/common.py | 5 ++ tests/components/synology_dsm/test_sensor.py | 71 ++++++++++++++++++- 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index a9f66e4762ea..85a847cbe805 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -523,6 +523,22 @@ class SynoDSMExternalUSBSensor(SynologyDSMDeviceEntity, SynoDSMSensor): return attr # type: ignore[no-any-return] + @property + def available(self) -> bool: + """Return True if entity is available.""" + external_usb = self._api.external_usb + assert external_usb is not None + if "device" in self.entity_description.key: + for device in external_usb.get_devices.values(): + if device.device_name == self._device_id: + return super().available + elif "partition" in self.entity_description.key: + for device in external_usb.get_devices.values(): + for partition in device.device_partitions.values(): + if partition.partition_title == self._device_id: + return super().available + return False + class SynoDSMInfoSensor(SynoDSMSensor): """Representation a Synology information sensor.""" diff --git a/tests/components/synology_dsm/common.py b/tests/components/synology_dsm/common.py index a9d05ce941e1..601f437c1072 100644 --- a/tests/components/synology_dsm/common.py +++ b/tests/components/synology_dsm/common.py @@ -112,6 +112,11 @@ def mock_dsm_storage_disks() -> list[SynoStorageDisk]: return [SynoStorageDisk(**disk_info) for disk_info in disks_data.values()] +def mock_dsm_external_usb_devices_usb0() -> dict[str, SynoCoreExternalUSBDevice]: + """Mock SynologyDSM external USB device with no USB.""" + return {} + + def mock_dsm_external_usb_devices_usb1() -> dict[str, SynoCoreExternalUSBDevice]: """Mock SynologyDSM external USB device with USB Disk 1.""" return { diff --git a/tests/components/synology_dsm/test_sensor.py b/tests/components/synology_dsm/test_sensor.py index a02728dcc4c7..f636dbb79a83 100644 --- a/tests/components/synology_dsm/test_sensor.py +++ b/tests/components/synology_dsm/test_sensor.py @@ -18,6 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from .common import ( + mock_dsm_external_usb_devices_usb0, mock_dsm_external_usb_devices_usb1, mock_dsm_external_usb_devices_usb2, mock_dsm_information, @@ -48,10 +49,10 @@ def mock_dsm_with_usb(): dsm.information = mock_dsm_information() dsm.storage = Mock( get_disk=mock_dsm_storage_get_disk, - disk_temp=Mock(return_value=32), disks_ids=["sata1", "sata2", "sata3"], + disk_temp=Mock(return_value=42), get_volume=mock_dsm_storage_get_volume, - volume_disk_temp_avg=Mock(return_value=32), + volume_disk_temp_avg=Mock(return_value=42), volume_size_used=Mock(return_value=12000138625024), volume_percentage_used=Mock(return_value=38), volumes_ids=["volume_1"], @@ -282,6 +283,72 @@ async def test_external_usb_new_device( assert sensor.attributes[attr_key] == attr_value +async def test_external_usb_availability( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + setup_dsm_with_usb: MagicMock, +) -> None: + """Test Synology DSM USB availability.""" + + expected_sensors_disk_1_available = { + "sensor.nas_meontheinternet_com_usb_disk_1_status": ("normal", {}), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_size": ( + "14901.998046875", + {}, + ), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used_space": ( + "5803.1650390625", + {}, + ), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used": ( + "38.9", + {}, + ), + } + expected_sensors_disk_1_unavailable = { + "sensor.nas_meontheinternet_com_usb_disk_1_status": ("unavailable", {}), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_size": ( + "unavailable", + {}, + ), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used_space": ( + "unavailable", + {}, + ), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used": ( + "unavailable", + {}, + ), + } + + # Initial check of existing sensors + for sensor_id, ( + expected_state, + expected_attrs, + ) in expected_sensors_disk_1_available.items(): + sensor = hass.states.get(sensor_id) + assert sensor is not None + assert sensor.state == expected_state + for attr_key, attr_value in expected_attrs.items(): + assert sensor.attributes[attr_key] == attr_value + + # Mock the get_devices method to simulate no USB devices being connected + setup_dsm_with_usb.external_usb.get_devices = mock_dsm_external_usb_devices_usb0() + # Coordinator refresh + await setup_dsm_with_usb.mock_entry.runtime_data.coordinator_central.async_request_refresh() + await hass.async_block_till_done() + + for sensor_id, ( + expected_state, + expected_attrs, + ) in expected_sensors_disk_1_unavailable.items(): + sensor = hass.states.get(sensor_id) + assert sensor is not None + assert sensor.state == expected_state + for attr_key, attr_value in expected_attrs.items(): + assert sensor.attributes[attr_key] == attr_value + + async def test_no_external_usb( hass: HomeAssistant, setup_dsm_without_usb: MagicMock, From 0a6ae3b52ab29202c6ee4ce649c70d53846fe313 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 24 Sep 2025 11:46:33 +0200 Subject: [PATCH 096/189] Add enum for Tuya device categories (#152858) --- .../components/tuya/alarm_control_panel.py | 10 +- homeassistant/components/tuya/button.py | 14 +- homeassistant/components/tuya/camera.py | 14 +- homeassistant/components/tuya/climate.py | 28 +- homeassistant/components/tuya/const.py | 260 ++++++++++++++++++ 5 files changed, 279 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index d08a3bef7ce5..a428635cae1d 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -19,7 +19,7 @@ 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, DPType +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity from .models import EnumTypeData from .util import get_dpcode @@ -57,12 +57,8 @@ STATE_MAPPING: dict[str, AlarmControlPanelState] = { } -# All descriptions can be found here: -# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -ALARM: dict[str, tuple[TuyaAlarmControlPanelEntityDescription, ...]] = { - # Alarm Host - # https://developer.tuya.com/en/docs/iot/categorymal?id=Kaiuz33clqxaf - "mal": ( +ALARM: dict[DeviceCategory, tuple[TuyaAlarmControlPanelEntityDescription, ...]] = { + DeviceCategory.MAL: ( TuyaAlarmControlPanelEntityDescription( key=DPCode.MASTER_MODE, master_state=DPCode.MASTER_STATE, diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py index 928e584e77d6..e11c7ea5383b 100644 --- a/homeassistant/components/tuya/button.py +++ b/homeassistant/components/tuya/button.py @@ -11,23 +11,17 @@ 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 .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -# All descriptions can be found here. -# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -BUTTONS: dict[str, tuple[ButtonEntityDescription, ...]] = { - # Wake Up Light II - # Not documented - "hxd": ( +BUTTONS: dict[DeviceCategory, tuple[ButtonEntityDescription, ...]] = { + DeviceCategory.HXD: ( ButtonEntityDescription( key=DPCode.SWITCH_USB6, translation_key="snooze", ), ), - # Robot Vacuum - # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo - "sd": ( + DeviceCategory.SD: ( ButtonEntityDescription( key=DPCode.RESET_DUSTER_CLOTH, translation_key="reset_duster_cloth", diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py index 788a9bcc5c3a..e0641b9b8a59 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -11,18 +11,12 @@ 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 .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -# All descriptions can be found here: -# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -CAMERAS: tuple[str, ...] = ( - # Smart Camera - Low power consumption camera - # Undocumented, see https://github.com/home-assistant/core/issues/132844 - "dghsxj", - # Smart Camera (including doorbells) - # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu - "sp", +CAMERAS: tuple[DeviceCategory, ...] = ( + DeviceCategory.DGHSXJ, + DeviceCategory.SP, ) diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index ecfc96f1d67e..57faba5b1547 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -24,7 +24,7 @@ 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, DPType +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity from .models import IntegerTypeData from .util import get_dpcode @@ -48,40 +48,28 @@ class TuyaClimateEntityDescription(ClimateEntityDescription): switch_only_hvac_mode: HVACMode -CLIMATE_DESCRIPTIONS: dict[str, TuyaClimateEntityDescription] = { - # Electric Fireplace - # https://developer.tuya.com/en/docs/iot/f?id=Kacpeobojffop - "dbl": TuyaClimateEntityDescription( +CLIMATE_DESCRIPTIONS: dict[DeviceCategory, TuyaClimateEntityDescription] = { + DeviceCategory.DBL: TuyaClimateEntityDescription( key="dbl", switch_only_hvac_mode=HVACMode.HEAT, ), - # Air conditioner - # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n - "kt": TuyaClimateEntityDescription( + DeviceCategory.KT: TuyaClimateEntityDescription( key="kt", switch_only_hvac_mode=HVACMode.COOL, ), - # Heater - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46epy4j82 - "qn": TuyaClimateEntityDescription( + DeviceCategory.QN: TuyaClimateEntityDescription( key="qn", switch_only_hvac_mode=HVACMode.HEAT, ), - # Heater - # https://developer.tuya.com/en/docs/iot/categoryrs?id=Kaiuz0nfferyx - "rs": TuyaClimateEntityDescription( + DeviceCategory.RS: TuyaClimateEntityDescription( key="rs", switch_only_hvac_mode=HVACMode.HEAT, ), - # Thermostat - # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 - "wk": TuyaClimateEntityDescription( + DeviceCategory.WK: TuyaClimateEntityDescription( key="wk", switch_only_hvac_mode=HVACMode.HEAT_COOL, ), - # Thermostatic Radiator Valve - # Not documented - "wkf": TuyaClimateEntityDescription( + DeviceCategory.WKF: TuyaClimateEntityDescription( key="wkf", switch_only_hvac_mode=HVACMode.HEAT, ), diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 81ef495dabc8..c0412e36625e 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -92,6 +92,266 @@ class DPType(StrEnum): STRING = "String" +class DeviceCategory(StrEnum): + """Tuya device categories. + + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + """ + + AMY = "amy" + """Massage chair""" + BGL = "bgl" + """Wall-hung boiler""" + BH = "bh" + """Smart kettle""" + BX = "bx" + """Refrigerator""" + BXX = "bxx" + """Safe box""" + CJKG = "cjkg" + """Scene switch""" + CKMKZQ = "ckmkzq" + """Garage door opener + + https://developer.tuya.com/en/docs/iot/categoryckmkzq?id=Kaiuz0ipcboee + """ + CKQDKG = "ckqdkg" + """Card switch""" + CL = "cl" + """Curtain + + https://developer.tuya.com/en/docs/iot/categorycl?id=Kaiuz1hnpo7df + """ + CLKG = "clkg" + """Curtain switch + + https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39 + """ + CN = "cn" + """Milk dispenser""" + CO2BJ = "co2bj" + """CO2 detector""" + COBJ = "cobj" + """CO detector""" + CS = "cs" + """Dehumidifier""" + CWTSWSQ = "cwtswsq" + """Pet treat feeder""" + CWWQFSQ = "cwwqfsq" + """Pet ball thrower""" + CWWSQ = "cwwsq" + """Pet feeder""" + CWYSJ = "cwysj" + """Pet fountain""" + CZ = "cz" + """Socket""" + DBL = "dbl" + """Electric fireplace + + https://developer.tuya.com/en/docs/iot/f?id=Kacpeobojffop + """ + DC = "dc" + """String lights""" + DCL = "dcl" + """Induction cooker""" + DD = "dd" + """Strip lights""" + DGNBJ = "dgnbj" + """Multi-functional alarm""" + DJ = "dj" + """Light""" + DLQ = "dlq" + """Circuit breaker""" + DR = "dr" + """Electric blanket""" + DS = "ds" + """TV set""" + FS = "fs" + """Fan""" + FSD = "fsd" + """Ceiling fan light""" + FWD = "fwd" + """Ambiance light""" + GGQ = "ggq" + """Irrigator""" + GYD = "gyd" + """Motion sensor light""" + GYMS = "gyms" + """Business lock""" + HOTELMS = "hotelms" + """Hotel lock""" + HPS = "hps" + """Human presence sensor""" + JS = "js" + """Water purifier""" + JSQ = "jsq" + """Humidifier""" + JTMSBH = "jtmsbh" + """Smart lock (keep alive)""" + JTMSPRO = "jtmspro" + """Residential lock pro""" + JWBJ = "jwbj" + """Methane detector""" + KFJ = "kfj" + """Coffee maker""" + KG = "kg" + """Switch""" + KJ = "kj" + """Air purifier""" + KQZG = "kqzg" + """Air fryer""" + KT = "kt" + """Air conditioner + + https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n + """ + KTKZQ = "ktkzq" + """Air conditioner controller""" + LDCG = "ldcg" + """Luminance sensor""" + LILIAO = "liliao" + """Physiotherapy product""" + LYJ = "lyj" + """Drying rack""" + MAL = "mal" + """Alarm host + + https://developer.tuya.com/en/docs/iot/categorymal?id=Kaiuz33clqxaf + """ + MB = "mb" + """Bread maker""" + MC = "mc" + """Door/window controller""" + MCS = "mcs" + """Contact sensor""" + MG = "mg" + """Rice cabinet""" + MJJ = "mjj" + """Towel rack""" + MK = "mk" + """Access control""" + MS = "ms" + """Residential lock""" + MS_CATEGORY = "ms_category" + """Lock accessories""" + MSP = "msp" + """Cat toilet""" + MZJ = "mzj" + """Sous vide cooker""" + NNQ = "nnq" + """Bottle warmer""" + NTQ = "ntq" + """HVAC""" + PC = "pc" + """Power strip""" + PHOTOLOCK = "photolock" + """Audio and video lock""" + PIR = "pir" + """Human motion sensor""" + PM2_5 = "pm2.5" + """PM2.5 detector""" + QN = "qn" + """Heater + + https://developer.tuya.com/en/docs/iot/f?id=K9gf46epy4j82 + """ + RQBJ = "rqbj" + """Gas alarm""" + RS = "rs" + """Water heater + + https://developer.tuya.com/en/docs/iot/categoryrs?id=Kaiuz0nfferyx + """ + SB = "sb" + """Watch/band""" + SD = "sd" + """Robot vacuum + + https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + """ + SF = "sf" + """Sofa""" + SGBJ = "sgbj" + """Siren alarm""" + SJ = "sj" + """Water leak detector""" + SOS = "sos" + """Emergency button""" + SP = "sp" + """Smart camera + + https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu + """ + SZ = "sz" + """Smart indoor garden""" + TGKG = "tgkg" + """Dimmer switch""" + TGQ = "tgq" + """Dimmer""" + TNQ = "tnq" + """Smart milk kettle""" + TRACKER = "tracker" + """Tracker""" + TS = "ts" + """Smart jump rope""" + TYNDJ = "tyndj" + """Solar light""" + TYY = "tyy" + """Projector""" + TZC1 = "tzc1" + """Body fat scale""" + VIDEOLOCK = "videolock" + """Lock with camera""" + WK = "wk" + """Thermostat + + https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 + """ + WSDCG = "wsdcg" + """Temperature and humidity sensor""" + XDD = "xdd" + """Ceiling light""" + XFJ = "xfj" + """Ventilation system""" + XXJ = "xxj" + """Diffuser""" + XY = "xy" + """Washing machine""" + YB = "yb" + """Bathroom heater""" + YG = "yg" + """Bathtub""" + YKQ = "ykq" + """Remote control""" + YLCG = "ylcg" + """Pressure sensor""" + YWBJ = "ywbj" + """Smoke alarm""" + ZD = "zd" + """Vibration sensor""" + ZNDB = "zndb" + """Smart electricity meter""" + ZNFH = "znfh" + """Bento box""" + ZNSB = "znsb" + """Smart water meter""" + ZNYH = "znyh" + """Smart pill box""" + + # Undocumented + DGHSXJ = "dghsxj" + """Smart Camera - Low power consumption camera (undocumented) + + see https://github.com/home-assistant/core/issues/132844 + """ + HXD = "hxd" + """Wake Up Light II (undocumented)""" + JDCLJQR = "jdcljqr" + """Curtain Robot (undocumented)""" + WKF = "wkf" + """Thermostatic Radiator Valve (undocumented)""" + + class DPCode(StrEnum): """Data Point Codes used by Tuya. From 934db458a386d27da7b834967194a0e2d8eead92 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 24 Sep 2025 11:47:28 +0200 Subject: [PATCH 097/189] Simplify access to Tuya device manager in async_setup_entry (#152859) --- .../components/tuya/alarm_control_panel.py | 8 ++++---- homeassistant/components/tuya/binary_sensor.py | 8 ++++---- homeassistant/components/tuya/button.py | 8 ++++---- homeassistant/components/tuya/camera.py | 8 ++++---- homeassistant/components/tuya/climate.py | 8 ++++---- homeassistant/components/tuya/cover.py | 8 ++++---- homeassistant/components/tuya/diagnostics.py | 16 +++++++--------- homeassistant/components/tuya/event.py | 10 ++++------ homeassistant/components/tuya/fan.py | 8 ++++---- homeassistant/components/tuya/humidifier.py | 10 ++++------ homeassistant/components/tuya/light.py | 8 ++++---- homeassistant/components/tuya/number.py | 8 ++++---- homeassistant/components/tuya/scene.py | 6 +++--- homeassistant/components/tuya/select.py | 8 ++++---- homeassistant/components/tuya/sensor.py | 8 ++++---- homeassistant/components/tuya/siren.py | 8 ++++---- homeassistant/components/tuya/switch.py | 8 ++++---- homeassistant/components/tuya/vacuum.py | 8 ++++---- homeassistant/components/tuya/valve.py | 8 ++++---- 19 files changed, 78 insertions(+), 84 deletions(-) diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index a428635cae1d..43105af0362c 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -75,23 +75,23 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya alarm dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya siren.""" entities: list[TuyaAlarmEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if descriptions := ALARM.get(device.category): entities.extend( - TuyaAlarmEntity(device, hass_data.manager, description) + TuyaAlarmEntity(device, manager, description) for description in descriptions if description.key in device.status ) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 08645b49e4cc..912de9464830 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -425,14 +425,14 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya binary sensor dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya binary sensor.""" entities: list[TuyaBinarySensorEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if descriptions := BINARY_SENSORS.get(device.category): for description in descriptions: dpcode = description.dpcode or description.key @@ -448,7 +448,7 @@ async def async_setup_entry( entities.append( TuyaBinarySensorEntity( device, - hass_data.manager, + manager, description, mask, ) @@ -456,7 +456,7 @@ async def async_setup_entry( async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py index e11c7ea5383b..013a02df0486 100644 --- a/homeassistant/components/tuya/button.py +++ b/homeassistant/components/tuya/button.py @@ -57,24 +57,24 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya buttons dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya buttons.""" entities: list[TuyaButtonEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if descriptions := BUTTONS.get(device.category): entities.extend( - TuyaButtonEntity(device, hass_data.manager, description) + TuyaButtonEntity(device, manager, description) for description in descriptions if description.key in device.status ) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py index e0641b9b8a59..93525c723da2 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -26,20 +26,20 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya cameras dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya camera.""" entities: list[TuyaCameraEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if device.category in CAMERAS: - entities.append(TuyaCameraEntity(device, hass_data.manager)) + entities.append(TuyaCameraEntity(device, manager)) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 57faba5b1547..ab1d8db16fa5 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -82,26 +82,26 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya climate dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya climate.""" entities: list[TuyaClimateEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if device and device.category in CLIMATE_DESCRIPTIONS: entities.append( TuyaClimateEntity( device, - hass_data.manager, + manager, CLIMATE_DESCRIPTIONS[device.category], hass.config.units.temperature_unit, ) ) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 8b02d0adbda6..3464b535c474 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -158,17 +158,17 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya cover dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered tuya cover.""" entities: list[TuyaCoverEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if descriptions := COVERS.get(device.category): entities.extend( - TuyaCoverEntity(device, hass_data.manager, description) + TuyaCoverEntity(device, manager, description) for description in descriptions if ( description.key in device.function @@ -178,7 +178,7 @@ async def async_setup_entry( async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/diagnostics.py b/homeassistant/components/tuya/diagnostics.py index 9675b215ce20..b71a17f68a6c 100644 --- a/homeassistant/components/tuya/diagnostics.py +++ b/homeassistant/components/tuya/diagnostics.py @@ -39,15 +39,15 @@ def _async_get_diagnostics( device: DeviceEntry | None = None, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager mqtt_connected = None - if hass_data.manager.mq.client: - mqtt_connected = hass_data.manager.mq.client.is_connected() + if manager.mq.client: + mqtt_connected = manager.mq.client.is_connected() data = { - "endpoint": hass_data.manager.customer_api.endpoint, - "terminal_id": hass_data.manager.terminal_id, + "endpoint": manager.customer_api.endpoint, + "terminal_id": manager.terminal_id, "mqtt_connected": mqtt_connected, "disabled_by": entry.disabled_by, "disabled_polling": entry.pref_disable_polling, @@ -55,14 +55,12 @@ def _async_get_diagnostics( if device: tuya_device_id = next(iter(device.identifiers))[1] - data |= _async_device_as_dict( - hass, hass_data.manager.device_map[tuya_device_id] - ) + data |= _async_device_as_dict(hass, manager.device_map[tuya_device_id]) else: data.update( devices=[ _async_device_as_dict(hass, device) - for device in hass_data.manager.device_map.values() + for device in manager.device_map.values() ] ) diff --git a/homeassistant/components/tuya/event.py b/homeassistant/components/tuya/event.py index 0c07844ffba2..5eda6cbe6bbe 100644 --- a/homeassistant/components/tuya/event.py +++ b/homeassistant/components/tuya/event.py @@ -89,25 +89,23 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya events dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya binary sensor.""" entities: list[TuyaEventEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if descriptions := EVENTS.get(device.category): for description in descriptions: dpcode = description.key if dpcode in device.status: - entities.append( - TuyaEventEntity(device, hass_data.manager, description) - ) + entities.append(TuyaEventEntity(device, manager, description)) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 12b6b11a2977..dc6d234cc5d4 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -76,19 +76,19 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tuya fan dynamically through tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered tuya fan.""" entities: list[TuyaFanEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if device.category in TUYA_SUPPORT_TYPE and _has_a_valid_dpcode(device): - entities.append(TuyaFanEntity(device, hass_data.manager)) + entities.append(TuyaFanEntity(device, manager)) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index cb08ccaf476e..3d90ff3b44ff 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -77,23 +77,21 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya (de)humidifier dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya (de)humidifier.""" entities: list[TuyaHumidifierEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if ( description := HUMIDIFIERS.get(device.category) ) and _has_a_valid_dpcode(device, description): - entities.append( - TuyaHumidifierEntity(device, hass_data.manager, description) - ) + entities.append(TuyaHumidifierEntity(device, manager, description)) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 9dba24ec490b..6b1ac3e991fb 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -470,24 +470,24 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tuya light dynamically through tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]): """Discover and add a discovered tuya light.""" entities: list[TuyaLightEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if descriptions := LIGHTS.get(device.category): entities.extend( - TuyaLightEntity(device, hass_data.manager, description) + TuyaLightEntity(device, manager, description) for description in descriptions if description.key in device.status ) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 6a4482821bad..30c1c03807e4 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -492,24 +492,24 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya number dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya number.""" entities: list[TuyaNumberEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if descriptions := NUMBERS.get(device.category): entities.extend( - TuyaNumberEntity(device, hass_data.manager, description) + TuyaNumberEntity(device, manager, description) for description in descriptions if description.key in device.status ) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/scene.py b/homeassistant/components/tuya/scene.py index 4ad027d39eed..239aabd9bccc 100644 --- a/homeassistant/components/tuya/scene.py +++ b/homeassistant/components/tuya/scene.py @@ -21,9 +21,9 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya scenes.""" - hass_data = entry.runtime_data - scenes = await hass.async_add_executor_job(hass_data.manager.query_scenes) - async_add_entities(TuyaSceneEntity(hass_data.manager, scene) for scene in scenes) + manager = entry.runtime_data.manager + scenes = await hass.async_add_executor_job(manager.query_scenes) + async_add_entities(TuyaSceneEntity(manager, scene) for scene in scenes) class TuyaSceneEntity(Scene): diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 0d62620b88e5..e16642305e7f 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -394,24 +394,24 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya select dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya select.""" entities: list[TuyaSelectEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if descriptions := SELECTS.get(device.category): entities.extend( - TuyaSelectEntity(device, hass_data.manager, description) + TuyaSelectEntity(device, manager, description) for description in descriptions if description.key in device.status ) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 0c2c1e8f9247..3851287ce466 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -1717,24 +1717,24 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya sensor dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya sensor.""" entities: list[TuyaSensorEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if descriptions := SENSORS.get(device.category): entities.extend( - TuyaSensorEntity(device, hass_data.manager, description) + TuyaSensorEntity(device, manager, description) for description in descriptions if description.key in device.status ) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py index 8003dc2cf212..e6849eb767ee 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -65,24 +65,24 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya siren dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya siren.""" entities: list[TuyaSirenEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if descriptions := SIRENS.get(device.category): entities.extend( - TuyaSirenEntity(device, hass_data.manager, description) + TuyaSirenEntity(device, manager, description) for description in descriptions if description.key in device.status ) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index f5324888d818..d34123e02711 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -999,7 +999,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tuya sensors dynamically through tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager entity_registry = er.async_get(hass) @callback @@ -1007,10 +1007,10 @@ async def async_setup_entry( """Discover and add a discovered tuya sensor.""" entities: list[TuyaSwitchEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if descriptions := SWITCHES.get(device.category): entities.extend( - TuyaSwitchEntity(device, hass_data.manager, description) + TuyaSwitchEntity(device, manager, description) for description in descriptions if description.key in device.status and _check_deprecation( @@ -1023,7 +1023,7 @@ async def async_setup_entry( async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index c32d773c7921..0d5ea1ee70da 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -55,19 +55,19 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya vacuum dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya vacuum.""" entities: list[TuyaVacuumEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if device.category == "sd": - entities.append(TuyaVacuumEntity(device, hass_data.manager)) + entities.append(TuyaVacuumEntity(device, manager)) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/valve.py b/homeassistant/components/tuya/valve.py index 42d4556a0d02..dcb63c00cc93 100644 --- a/homeassistant/components/tuya/valve.py +++ b/homeassistant/components/tuya/valve.py @@ -87,24 +87,24 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tuya valves dynamically through tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @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] + device = manager.device_map[device_id] if descriptions := VALVES.get(device.category): entities.extend( - TuyaValveEntity(device, hass_data.manager, description) + TuyaValveEntity(device, manager, description) for description in descriptions if description.key in device.status ) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) From 023ecf2a642c75ee11411d8e881b54c0cfbca529 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 24 Sep 2025 10:49:01 +0100 Subject: [PATCH 098/189] Patch async_setup_entry in hardware integration flow tests (#152871) --- .../homeassistant_connect_zbt2/test_config_flow.py | 10 ++++++++++ .../homeassistant_sky_connect/test_config_flow.py | 10 ++++++++++ .../homeassistant_yellow/test_config_flow.py | 10 ++++++++++ 3 files changed, 30 insertions(+) diff --git a/tests/components/homeassistant_connect_zbt2/test_config_flow.py b/tests/components/homeassistant_connect_zbt2/test_config_flow.py index e3b4f7a66f52..b1372fe44832 100644 --- a/tests/components/homeassistant_connect_zbt2/test_config_flow.py +++ b/tests/components/homeassistant_connect_zbt2/test_config_flow.py @@ -34,6 +34,16 @@ def mock_supervisor_fixture() -> Generator[None]: yield +@pytest.fixture(name="setup_entry", autouse=True) +def setup_entry_fixture() -> Generator[AsyncMock]: + """Mock entry setup.""" + with patch( + "homeassistant.components.homeassistant_connect_zbt2.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + async def test_config_flow_zigbee( hass: HomeAssistant, ) -> None: diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 2b863450d7df..6fd4b05a13ee 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -40,6 +40,16 @@ def mock_supervisor_fixture() -> Generator[None]: yield +@pytest.fixture(name="setup_entry", autouse=True) +def setup_entry_fixture() -> Generator[AsyncMock]: + """Mock entry setup.""" + with patch( + "homeassistant.components.homeassistant_sky_connect.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.mark.parametrize( ("usb_data", "model"), [ diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 518a1d3b4d19..160e470ad1e6 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -76,6 +76,16 @@ def mock_reboot_host(supervisor_client: AsyncMock) -> AsyncMock: return supervisor_client.host.reboot +@pytest.fixture(name="setup_entry", autouse=True) +def setup_entry_fixture() -> Generator[AsyncMock]: + """Mock entry setup.""" + with patch( + "homeassistant.components.homeassistant_yellow.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + async def test_config_flow(hass: HomeAssistant) -> None: """Test the config flow.""" mock_integration(hass, MockModule("hassio")) From 7d1953e3871668f7a69efd96ad0e2806dbbcc9af Mon Sep 17 00:00:00 2001 From: Richard Polzer Date: Wed, 24 Sep 2025 11:54:27 +0200 Subject: [PATCH 099/189] Add Ekey Bionyx integration (#139132) Co-authored-by: Erik Montnemery --- CODEOWNERS | 2 + .../components/ekeybionyx/__init__.py | 24 ++ .../ekeybionyx/application_credentials.py | 14 + .../components/ekeybionyx/config_flow.py | 271 +++++++++++++ homeassistant/components/ekeybionyx/const.py | 13 + homeassistant/components/ekeybionyx/event.py | 70 ++++ .../components/ekeybionyx/manifest.json | 11 + .../components/ekeybionyx/quality_scale.yaml | 92 +++++ .../components/ekeybionyx/strings.json | 66 ++++ .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/ekeybionyx/__init__.py | 1 + tests/components/ekeybionyx/conftest.py | 173 +++++++++ .../components/ekeybionyx/test_config_flow.py | 360 ++++++++++++++++++ tests/components/ekeybionyx/test_init.py | 30 ++ 18 files changed, 1141 insertions(+) create mode 100644 homeassistant/components/ekeybionyx/__init__.py create mode 100644 homeassistant/components/ekeybionyx/application_credentials.py create mode 100644 homeassistant/components/ekeybionyx/config_flow.py create mode 100644 homeassistant/components/ekeybionyx/const.py create mode 100644 homeassistant/components/ekeybionyx/event.py create mode 100644 homeassistant/components/ekeybionyx/manifest.json create mode 100644 homeassistant/components/ekeybionyx/quality_scale.yaml create mode 100644 homeassistant/components/ekeybionyx/strings.json create mode 100644 tests/components/ekeybionyx/__init__.py create mode 100644 tests/components/ekeybionyx/conftest.py create mode 100644 tests/components/ekeybionyx/test_config_flow.py create mode 100644 tests/components/ekeybionyx/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 0b6a1a8177f5..46413e834fc1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -410,6 +410,8 @@ build.json @home-assistant/supervisor /homeassistant/components/egardia/ @jeroenterheerdt /homeassistant/components/eheimdigital/ @autinerd /tests/components/eheimdigital/ @autinerd +/homeassistant/components/ekeybionyx/ @richardpolzer +/tests/components/ekeybionyx/ @richardpolzer /homeassistant/components/electrasmart/ @jafar-atili /tests/components/electrasmart/ @jafar-atili /homeassistant/components/electric_kiwi/ @mikey0000 diff --git a/homeassistant/components/ekeybionyx/__init__.py b/homeassistant/components/ekeybionyx/__init__.py new file mode 100644 index 000000000000..672824b811ac --- /dev/null +++ b/homeassistant/components/ekeybionyx/__init__.py @@ -0,0 +1,24 @@ +"""The Ekey Bionyx integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.EVENT] + + +type EkeyBionyxConfigEntry = ConfigEntry + + +async def async_setup_entry(hass: HomeAssistant, entry: EkeyBionyxConfigEntry) -> bool: + """Set up the Ekey Bionyx config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: EkeyBionyxConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ekeybionyx/application_credentials.py b/homeassistant/components/ekeybionyx/application_credentials.py new file mode 100644 index 000000000000..d6b7918af6bd --- /dev/null +++ b/homeassistant/components/ekeybionyx/application_credentials.py @@ -0,0 +1,14 @@ +"""application_credentials platform the Ekey Bionyx integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/ekeybionyx/config_flow.py b/homeassistant/components/ekeybionyx/config_flow.py new file mode 100644 index 000000000000..cdf0538eea50 --- /dev/null +++ b/homeassistant/components/ekeybionyx/config_flow.py @@ -0,0 +1,271 @@ +"""Config flow for ekey bionyx.""" + +import asyncio +import json +import logging +import re +import secrets +from typing import Any, NotRequired, TypedDict + +import aiohttp +import ekey_bionyxpy +import voluptuous as vol + +from homeassistant.components.webhook import ( + async_generate_id as webhook_generate_id, + async_generate_path as webhook_generate_path, +) +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_TOKEN, CONF_URL +from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.network import get_url +from homeassistant.helpers.selector import SelectOptionDict, SelectSelector + +from .const import API_URL, DOMAIN, INTEGRATION_NAME, SCOPE + +# Valid webhook name: starts with letter or underscore, contains letters, digits, spaces, dots, and underscores, does not end with space or dot +VALID_NAME_PATTERN = re.compile(r"^(?![\d\s])[\w\d \.]*[\w\d]$") + + +class ConfigFlowEkeyApi(ekey_bionyxpy.AbstractAuth): + """ekey bionyx authentication before a ConfigEntry exists. + + This implementation directly provides the token without supporting refresh. + """ + + def __init__( + self, + websession: aiohttp.ClientSession, + token: dict[str, Any], + ) -> None: + """Initialize ConfigFlowEkeyApi.""" + super().__init__(websession, API_URL) + self._token = token + + async def async_get_access_token(self) -> str: + """Return the token for the Ekey API.""" + return self._token["access_token"] + + +class EkeyFlowData(TypedDict): + """Type for Flow Data.""" + + api: NotRequired[ekey_bionyxpy.BionyxAPI] + system: NotRequired[ekey_bionyxpy.System] + systems: NotRequired[list[ekey_bionyxpy.System]] + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle ekey bionyx OAuth2 authentication.""" + + DOMAIN = DOMAIN + + check_deletion_task: asyncio.Task[None] | None = None + + def __init__(self) -> None: + """Initialize OAuth2FlowHandler.""" + super().__init__() + self._data: EkeyFlowData = {} + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return {"scope": SCOPE} + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Start the user facing flow by initializing the API and getting the systems.""" + client = ConfigFlowEkeyApi(async_get_clientsession(self.hass), data[CONF_TOKEN]) + ap = ekey_bionyxpy.BionyxAPI(client) + self._data["api"] = ap + try: + system_res = await ap.get_systems() + except aiohttp.ClientResponseError: + return self.async_abort( + reason="cannot_connect", + description_placeholders={"ekeybionyx": INTEGRATION_NAME}, + ) + system = [s for s in system_res if s.own_system] + if len(system) == 0: + return self.async_abort(reason="no_own_systems") + self._data["systems"] = system + if len(system) == 1: + # skipping choose_system since there is only one + self._data["system"] = system[0] + return await self.async_step_check_system(user_input=None) + return await self.async_step_choose_system(user_input=None) + + async def async_step_choose_system( + self, user_input: dict[str, Any] | None + ) -> ConfigFlowResult: + """Dialog to choose System if multiple systems are present.""" + if user_input is None: + options: list[SelectOptionDict] = [ + {"value": s.system_id, "label": s.system_name} + for s in self._data["systems"] + ] + data_schema = {vol.Required("system"): SelectSelector({"options": options})} + return self.async_show_form( + step_id="choose_system", + data_schema=vol.Schema(data_schema), + description_placeholders={"ekeybionyx": INTEGRATION_NAME}, + ) + self._data["system"] = [ + s for s in self._data["systems"] if s.system_id == user_input["system"] + ][0] + return await self.async_step_check_system(user_input=None) + + async def async_step_check_system( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Check if system has open webhooks.""" + system = self._data["system"] + await self.async_set_unique_id(system.system_id) + self._abort_if_unique_id_configured() + + if ( + system.function_webhook_quotas["free"] == 0 + and system.function_webhook_quotas["used"] == 0 + ): + return self.async_abort( + reason="no_available_webhooks", + description_placeholders={"ekeybionyx": INTEGRATION_NAME}, + ) + + if system.function_webhook_quotas["used"] > 0: + return await self.async_step_delete_webhooks() + return await self.async_step_webhooks(user_input=None) + + async def async_step_webhooks( + self, user_input: dict[str, Any] | None + ) -> ConfigFlowResult: + """Dialog to setup webhooks.""" + system = self._data["system"] + + errors: dict[str, str] | None = None + if user_input is not None: + errors = {} + for key, webhook_name in user_input.items(): + if key == CONF_URL: + continue + if not re.match(VALID_NAME_PATTERN, webhook_name): + errors.update({key: "invalid_name"}) + try: + cv.url(user_input[CONF_URL]) + except vol.Invalid: + errors[CONF_URL] = "invalid_url" + if set(user_input) == {CONF_URL}: + errors["base"] = "no_webhooks_provided" + + if not errors: + webhook_data = [ + { + "auth": secrets.token_hex(32), + "name": webhook_name, + "webhook_id": webhook_generate_id(), + } + for key, webhook_name in user_input.items() + if key != CONF_URL + ] + for webhook in webhook_data: + wh_def: ekey_bionyxpy.WebhookData = { + "integrationName": "Home Assistant", + "functionName": webhook["name"], + "locationName": "Home Assistant", + "definition": { + "url": user_input[CONF_URL] + + webhook_generate_path(webhook["webhook_id"]), + "authentication": {"apiAuthenticationType": "None"}, + "securityLevel": "AllowHttp", + "method": "Post", + "body": { + "contentType": "application/json", + "content": json.dumps({"auth": webhook["auth"]}), + }, + }, + } + webhook["ekey_id"] = (await system.add_webhook(wh_def)).webhook_id + return self.async_create_entry( + title=self._data["system"].system_name, + data={"webhooks": webhook_data}, + ) + + data_schema: dict[Any, Any] = { + vol.Optional(f"webhook{i + 1}"): vol.All(str, vol.Length(max=50)) + for i in range(self._data["system"].function_webhook_quotas["free"]) + } + data_schema[vol.Required(CONF_URL)] = str + return self.async_show_form( + step_id="webhooks", + data_schema=self.add_suggested_values_to_schema( + vol.Schema(data_schema), + { + CONF_URL: get_url( + self.hass, + allow_ip=True, + prefer_external=False, + ) + } + | (user_input or {}), + ), + errors=errors, + description_placeholders={ + "webhooks_available": str( + self._data["system"].function_webhook_quotas["free"] + ), + "ekeybionyx": INTEGRATION_NAME, + }, + ) + + async def async_step_delete_webhooks( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Form to delete Webhooks.""" + if user_input is None: + return self.async_show_form(step_id="delete_webhooks") + for webhook in await self._data["system"].get_webhooks(): + await webhook.delete() + return await self.async_step_wait_for_deletion(user_input=None) + + async def async_step_wait_for_deletion( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Wait for webhooks to be deleted in another flow.""" + uncompleted_task: asyncio.Task[None] | None = None + + if not self.check_deletion_task: + self.check_deletion_task = self.hass.async_create_task( + self.async_check_deletion_status() + ) + if not self.check_deletion_task.done(): + progress_action = "check_deletion_status" + uncompleted_task = self.check_deletion_task + if uncompleted_task: + return self.async_show_progress( + step_id="wait_for_deletion", + description_placeholders={"ekeybionyx": INTEGRATION_NAME}, + progress_action=progress_action, + progress_task=uncompleted_task, + ) + self.check_deletion_task = None + return self.async_show_progress_done(next_step_id="webhooks") + + async def async_check_deletion_status(self) -> None: + """Check if webhooks have been deleted.""" + while True: + self._data["systems"] = await self._data["api"].get_systems() + self._data["system"] = [ + s + for s in self._data["systems"] + if s.system_id == self._data["system"].system_id + ][0] + if self._data["system"].function_webhook_quotas["used"] == 0: + break + await asyncio.sleep(5) diff --git a/homeassistant/components/ekeybionyx/const.py b/homeassistant/components/ekeybionyx/const.py new file mode 100644 index 000000000000..eaf5b87f874a --- /dev/null +++ b/homeassistant/components/ekeybionyx/const.py @@ -0,0 +1,13 @@ +"""Constants for the Ekey Bionyx integration.""" + +import logging + +DOMAIN = "ekeybionyx" +INTEGRATION_NAME = "ekey bionyx" + +LOGGER = logging.getLogger(__package__) + +OAUTH2_AUTHORIZE = "https://ekeybionyxprod.b2clogin.com/ekeybionyxprod.onmicrosoft.com/B2C_1_sign_in_v2/oauth2/v2.0/authorize" +OAUTH2_TOKEN = "https://ekeybionyxprod.b2clogin.com/ekeybionyxprod.onmicrosoft.com/B2C_1_sign_in_v2/oauth2/v2.0/token" +API_URL = "https://api.bionyx.io/3rd-party/api" +SCOPE = "https://ekeybionyxprod.onmicrosoft.com/3rd-party-api/api-access" diff --git a/homeassistant/components/ekeybionyx/event.py b/homeassistant/components/ekeybionyx/event.py new file mode 100644 index 000000000000..b847637465b8 --- /dev/null +++ b/homeassistant/components/ekeybionyx/event.py @@ -0,0 +1,70 @@ +"""Event platform for ekey bionyx integration.""" + +from aiohttp.hdrs import METH_POST +from aiohttp.web import Request, Response + +from homeassistant.components.event import EventDeviceClass, EventEntity +from homeassistant.components.webhook import ( + async_register as webhook_register, + async_unregister as webhook_unregister, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import EkeyBionyxConfigEntry +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EkeyBionyxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Ekey event.""" + async_add_entities(EkeyEvent(data) for data in entry.data["webhooks"]) + + +class EkeyEvent(EventEntity): + """Ekey Event.""" + + _attr_device_class = EventDeviceClass.BUTTON + _attr_event_types = ["event happened"] + + def __init__( + self, + data: dict[str, str], + ) -> None: + """Initialise a Ekey event entity.""" + self._attr_name = data["name"] + self._attr_unique_id = data["ekey_id"] + self._webhook_id = data["webhook_id"] + self._auth = data["auth"] + + @callback + def _async_handle_event(self) -> None: + """Handle the webhook event.""" + self._trigger_event("event happened") + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register callbacks with your device API/library.""" + + async def async_webhook_handler( + hass: HomeAssistant, webhook_id: str, request: Request + ) -> Response | None: + if (await request.json())["auth"] == self._auth: + self._async_handle_event() + return None + + webhook_register( + self.hass, + DOMAIN, + f"Ekey {self._attr_name}", + self._webhook_id, + async_webhook_handler, + allowed_methods=[METH_POST], + ) + + async def async_will_remove_from_hass(self) -> None: + """Unregister Webhook.""" + webhook_unregister(self.hass, self._webhook_id) diff --git a/homeassistant/components/ekeybionyx/manifest.json b/homeassistant/components/ekeybionyx/manifest.json new file mode 100644 index 000000000000..a53dc13b9936 --- /dev/null +++ b/homeassistant/components/ekeybionyx/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "ekeybionyx", + "name": "ekey bionyx", + "codeowners": ["@richardpolzer"], + "config_flow": true, + "dependencies": ["application_credentials", "http"], + "documentation": "https://www.home-assistant.io/integrations/ekeybionyx", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["ekey-bionyxpy==1.0.0"] +} diff --git a/homeassistant/components/ekeybionyx/quality_scale.yaml b/homeassistant/components/ekeybionyx/quality_scale.yaml new file mode 100644 index 000000000000..13122e56adf5 --- /dev/null +++ b/homeassistant/components/ekeybionyx/quality_scale.yaml @@ -0,0 +1,92 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide actions. + appropriate-polling: + status: exempt + comment: This integration does not poll. + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide actions. + docs-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: This integration does not connect to any device or service. + test-before-configure: done + test-before-setup: + status: exempt + comment: This integration does not connect to any device or service. + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not provide actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: + status: exempt + comment: This integration has no way of knowing if the fingerprint reader is offline. + integration-owner: done + log-when-unavailable: + status: exempt + comment: This integration has no way of knowing if the fingerprint reader is offline. + parallel-updates: + status: exempt + comment: This integration does not poll. + reauthentication-flow: + status: exempt + comment: This integration does not store the tokens. + test-coverage: todo + + # Gold + devices: + status: exempt + comment: This integration does not connect to any device or service. + diagnostics: todo + discovery-update-info: + status: exempt + comment: This integration does not support discovery. + discovery: + status: exempt + comment: This integration does not support discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: This integration does not connect to any device or service. + entity-category: todo + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: This integration has no entities that should be disabled by default. + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: This integration does not connect to any device or service. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/ekeybionyx/strings.json b/homeassistant/components/ekeybionyx/strings.json new file mode 100644 index 000000000000..525189d5a71f --- /dev/null +++ b/homeassistant/components/ekeybionyx/strings.json @@ -0,0 +1,66 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "choose_system": { + "data": { + "system": "System" + }, + "data_description": { + "system": "System the event entities should be set up for." + }, + "description": "Please select the {ekeybionyx} system which you want to connect to Home Assistant." + }, + "webhooks": { + "description": "Please name your event entities. These event entities will be mapped as functions in the {ekeybionyx} app. You can configure up to {webhooks_available} event entities. Leaving a name empty will skip the setup of that event entity.", + "data": { + "webhook1": "Event entity 1", + "webhook2": "Event entity 2", + "webhook3": "Event entity 3", + "webhook4": "Event entity 4", + "webhook5": "Event entity 5", + "url": "Home Assistant URL" + }, + "data_description": { + "webhook1": "Name of event entity 1 that will be mapped into a function", + "webhook2": "Name of event entity 2 that will be mapped into a function", + "webhook3": "Name of event entity 3 that will be mapped into a function", + "webhook4": "Name of event entity 4 that will be mapped into a function", + "webhook5": "Name of event entity 5 that will be mapped into a function", + "url": "Home Assistant instance URL which can be reached from the fingerprint controller" + } + }, + "delete_webhooks": { + "description": "This system has already been connected to Home Assistant. If you continue, the previously configured functions will be deleted." + } + }, + "progress": { + "check_deletion_status": "Please go to the {ekeybionyx} app and confirm the deletion of the functions." + }, + "error": { + "invalid_name": "Name is invalid", + "invalid_url": "URL is invalid", + "no_webhooks_provided": "No event names provided" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "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%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "no_available_webhooks": "There are no available webhooks in the {ekeybionyx} plattform. Please delete some and try again.", + "no_own_systems": "Your account does not have admin access to any systems.", + "cannot_connect": "Connection to {ekeybionyx} failed. Please check your Internet connection and try again." + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 6d41c0c379db..38cd82a39d74 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -6,6 +6,7 @@ To update, run python3 -m script.hassfest APPLICATION_CREDENTIALS = [ "aladdin_connect", "august", + "ekeybionyx", "electric_kiwi", "fitbit", "geocaching", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a3b7aa63060f..5cdff2219574 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -168,6 +168,7 @@ FLOWS = { "edl21", "efergy", "eheimdigital", + "ekeybionyx", "electrasmart", "electric_kiwi", "elevenlabs", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1b72bed62b96..f060e3cb96e7 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1609,6 +1609,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "ekeybionyx": { + "name": "ekey bionyx", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "electrasmart": { "name": "Electra Smart", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index d5bcaf1a1c96..28e8de55eb6b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -852,6 +852,9 @@ ecoaliface==0.4.0 # homeassistant.components.eheimdigital eheimdigital==1.3.0 +# homeassistant.components.ekeybionyx +ekey-bionyxpy==1.0.0 + # homeassistant.components.electric_kiwi electrickiwi-api==0.9.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f178f419f344..535a8812f3af 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -743,6 +743,9 @@ easyenergy==2.1.2 # homeassistant.components.eheimdigital eheimdigital==1.3.0 +# homeassistant.components.ekeybionyx +ekey-bionyxpy==1.0.0 + # homeassistant.components.electric_kiwi electrickiwi-api==0.9.14 diff --git a/tests/components/ekeybionyx/__init__.py b/tests/components/ekeybionyx/__init__.py new file mode 100644 index 000000000000..334b000c57b4 --- /dev/null +++ b/tests/components/ekeybionyx/__init__.py @@ -0,0 +1 @@ +"""Tests for the Ekey Bionyx integration.""" diff --git a/tests/components/ekeybionyx/conftest.py b/tests/components/ekeybionyx/conftest.py new file mode 100644 index 000000000000..b6fc9be1572c --- /dev/null +++ b/tests/components/ekeybionyx/conftest.py @@ -0,0 +1,173 @@ +"""Conftest module for ekeybionyx.""" + +from http import HTTPStatus +from unittest.mock import patch + +import pytest + +from homeassistant.components.ekeybionyx.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +def dummy_systems( + num_systems: int, free_wh: int, used_wh: int, own_system: bool = True +) -> list[dict]: + """Create dummy systems.""" + return [ + { + "systemName": f"System {i + 1}", + "systemId": f"946DA01F-9ABD-4D9D-80C7-02AF85C822A{i + 8}", + "ownSystem": own_system, + "functionWebhookQuotas": {"free": free_wh, "used": used_wh}, + } + for i in range(num_systems) + ] + + +@pytest.fixture(name="system") +def mock_systems( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.get( + "https://api.bionyx.io/3rd-party/api/systems", + json=dummy_systems(2, 5, 0), + ) + + +@pytest.fixture(name="no_own_system") +def mock_no_own_systems( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.get( + "https://api.bionyx.io/3rd-party/api/systems", + json=dummy_systems(1, 1, 0, False), + ) + + +@pytest.fixture(name="no_response") +def mock_no_response( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.get( + "https://api.bionyx.io/3rd-party/api/systems", + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + +@pytest.fixture(name="no_available_webhooks") +def mock_no_available_webhooks( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.get( + "https://api.bionyx.io/3rd-party/api/systems", + json=dummy_systems(1, 0, 0), + ) + + +@pytest.fixture(name="already_set_up") +def mock_already_set_up( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.get( + "https://api.bionyx.io/3rd-party/api/systems", + json=dummy_systems(1, 0, 1), + ) + + +@pytest.fixture(name="webhooks") +def mock_webhooks( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.get( + "https://api.bionyx.io/3rd-party/api/systems/946DA01F-9ABD-4D9D-80C7-02AF85C822A8/function-webhooks", + json=[ + { + "functionWebhookId": "946DA01F-9ABD-4D9D-80C7-02AF85C822B9", + "integrationName": "Home Assistant", + "locationName": "A simple string containing 0 to 128 word, space and punctuation characters.", + "functionName": "A simple string containing 0 to 50 word, space and punctuation characters.", + "expiresAt": "2022-05-16T04:11:28.0000000+00:00", + "modificationState": None, + } + ], + ) + + +@pytest.fixture(name="webhook_deletion") +def mock_webhook_deletion( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.delete( + "https://api.bionyx.io/3rd-party/api/systems/946DA01F-9ABD-4D9D-80C7-02AF85C822A8/function-webhooks/946DA01F-9ABD-4D9D-80C7-02AF85C822B9", + status=HTTPStatus.ACCEPTED, + ) + + +@pytest.fixture(name="add_webhook", autouse=True) +def mock_add_webhook( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.post( + "https://api.bionyx.io/3rd-party/api/systems/946DA01F-9ABD-4D9D-80C7-02AF85C822A8/function-webhooks", + status=HTTPStatus.CREATED, + json={ + "functionWebhookId": "946DA01F-9ABD-4D9D-80C7-02AF85C822A8", + "integrationName": "Home Assistant", + "locationName": "Home Assistant", + "functionName": "Test", + "expiresAt": "2022-05-16T04:11:28.0000000+00:00", + "modificationState": None, + }, + ) + + +@pytest.fixture(name="webhook_id") +def mock_webhook_id(): + """Mock webhook_id.""" + with patch( + "homeassistant.components.webhook.async_generate_id", return_value="1234567890" + ): + yield + + +@pytest.fixture(name="token_hex") +def mock_token_hex(): + """Mock auth property.""" + with patch( + "secrets.token_hex", + return_value="f2156edca7fc6871e13845314a6fc68622e5ad7c58f17663a487ed28cac247f7", + ): + yield + + +@pytest.fixture(name="config_entry") +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create mocked config entry.""" + return MockConfigEntry( + title="test@test.com", + domain=DOMAIN, + data={ + "webhooks": [ + { + "webhook_id": "a2156edca7fb6671e13845314f6fc68622e5dd7c58f17663a487bd28cac247e7", + "name": "Test1", + "auth": "f2156edca7fc6871e13845314a6fc68622e5ad7c58f17663a487ed28cac247f7", + "ekey_id": "946DA01F-9ABD-4D9D-80C7-02AF85C822A8", + } + ] + }, + unique_id="946DA01F-9ABD-4D9D-80C7-02AF85C822A8", + version=1, + minor_version=1, + ) diff --git a/tests/components/ekeybionyx/test_config_flow.py b/tests/components/ekeybionyx/test_config_flow.py new file mode 100644 index 000000000000..f50cd099dbc4 --- /dev/null +++ b/tests/components/ekeybionyx/test_config_flow.py @@ -0,0 +1,360 @@ +"""Test the ekey bionyx config flow.""" + +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.ekeybionyx.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, + SCOPE, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.setup import async_setup_component + +from .conftest import dummy_systems + +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + webhook_id: None, + system: None, + token_hex: None, +) -> None: + """Check full 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": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + f"&scope={SCOPE}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + flow = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert flow.get("step_id") == "choose_system" + + flow2 = await hass.config_entries.flow.async_configure( + flow["flow_id"], {"system": "946DA01F-9ABD-4D9D-80C7-02AF85C822A8"} + ) + assert flow2.get("step_id") == "webhooks" + + flow3 = await hass.config_entries.flow.async_configure( + flow2["flow_id"], + { + "url": "localhost:8123", + }, + ) + + assert flow3.get("errors") == {"base": "no_webhooks_provided", "url": "invalid_url"} + + flow4 = await hass.config_entries.flow.async_configure( + flow3["flow_id"], + { + "webhook1": "Test ", + "webhook2": " Invalid", + "webhook3": "1Invalid", + "webhook4": "Also@Invalid", + "webhook5": "Invalid-Name", + "url": "localhost:8123", + }, + ) + + assert flow4.get("errors") == { + "url": "invalid_url", + "webhook1": "invalid_name", + "webhook2": "invalid_name", + "webhook3": "invalid_name", + "webhook4": "invalid_name", + "webhook5": "invalid_name", + } + + with patch( + "homeassistant.components.ekeybionyx.async_setup_entry", return_value=True + ) as mock_setup: + flow5 = await hass.config_entries.flow.async_configure( + flow2["flow_id"], + { + "webhook1": "Test", + "url": "http://localhost:8123", + }, + ) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert hass.config_entries.async_entries(DOMAIN)[0].data == { + "webhooks": [ + { + "webhook_id": "1234567890", + "name": "Test", + "auth": "f2156edca7fc6871e13845314a6fc68622e5ad7c58f17663a487ed28cac247f7", + "ekey_id": "946DA01F-9ABD-4D9D-80C7-02AF85C822A8", + } + ] + } + + assert flow5.get("type") is FlowResultType.CREATE_ENTRY + + assert len(mock_setup.mock_calls) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_no_own_system( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + no_own_system: None, +) -> None: + """Check no own System 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": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + f"&scope={SCOPE}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + flow = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + + assert flow.get("type") is FlowResultType.ABORT + assert flow.get("reason") == "no_own_systems" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_no_available_webhooks( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + no_available_webhooks: None, +) -> None: + """Check no own System 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": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + f"&scope={SCOPE}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + flow = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + + assert flow.get("type") is FlowResultType.ABORT + assert flow.get("reason") == "no_available_webhooks" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_cleanup( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + already_set_up: None, + webhooks: None, + webhook_deletion: None, +) -> None: + """Check no own System 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": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + f"&scope={SCOPE}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + flow = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert flow.get("step_id") == "delete_webhooks" + + flow2 = await hass.config_entries.flow.async_configure(flow["flow_id"], {}) + assert flow2.get("type") is FlowResultType.SHOW_PROGRESS + + aioclient_mock.clear_requests() + + aioclient_mock.get( + "https://api.bionyx.io/3rd-party/api/systems", + json=dummy_systems(1, 1, 0), + ) + + await hass.async_block_till_done() + + assert ( + hass.config_entries.flow.async_get(flow2["flow_id"]).get("step_id") + == "webhooks" + ) + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_error_on_setup( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + no_response: None, +) -> None: + """Check no own System 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": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + f"&scope={SCOPE}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + flow = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + + assert flow.get("type") is FlowResultType.ABORT + assert flow.get("reason") == "cannot_connect" diff --git a/tests/components/ekeybionyx/test_init.py b/tests/components/ekeybionyx/test_init.py new file mode 100644 index 000000000000..992d60c30344 --- /dev/null +++ b/tests/components/ekeybionyx/test_init.py @@ -0,0 +1,30 @@ +"""Module contains tests for the ekeybionyx component's initialization. + +Functions: + test_async_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + Test a successful setup entry and unload of entry. +""" + +from homeassistant.components.ekeybionyx.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_async_setup_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test a successful setup entry and unload of entry.""" + config_entry.add_to_hass(hass) + + 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 await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED From 9d1c7dadff1879bf59d36311d5377326502e4196 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 24 Sep 2025 11:55:28 +0200 Subject: [PATCH 100/189] Make SmartThings AC preset modes translatable (#152833) --- .../components/smartthings/climate.py | 35 +- .../components/smartthings/strings.json | 15 + tests/components/smartthings/conftest.py | 1 - .../device_status/da_ac_rac_000002.json | 886 ------------------ .../fixtures/devices/da_ac_rac_000002.json | 303 ------ .../smartthings/snapshots/test_climate.ambr | 187 +--- .../smartthings/snapshots/test_init.ambr | 31 - .../smartthings/snapshots/test_sensor.ambr | 440 --------- tests/components/smartthings/test_climate.py | 74 +- 9 files changed, 99 insertions(+), 1873 deletions(-) delete mode 100644 tests/components/smartthings/fixtures/device_status/da_ac_rac_000002.json delete mode 100644 tests/components/smartthings/fixtures/devices/da_ac_rac_000002.json diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 98581af9fe89..28c1c9c37825 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -14,6 +14,9 @@ from homeassistant.components.climate import ( ATTR_TARGET_TEMP_LOW, DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, + PRESET_BOOST, + PRESET_NONE, + PRESET_SLEEP, SWING_BOTH, SWING_HORIZONTAL, SWING_OFF, @@ -97,6 +100,19 @@ HEAT_PUMP_AC_MODE_TO_HA = { "heat": HVACMode.HEAT, } +PRESET_MODE_TO_HA = { + "off": PRESET_NONE, + "windFree": "wind_free", + "sleep": PRESET_SLEEP, + "windFreeSleep": "wind_free_sleep", + "speed": PRESET_BOOST, + "quiet": "quiet", + "longWind": "long_wind", + "smart": "smart", +} + +HA_MODE_TO_PRESET_MODE = {v: k for k, v in PRESET_MODE_TO_HA.items()} + HA_MODE_TO_HEAT_PUMP_AC_MODE = {v: k for k, v in HEAT_PUMP_AC_MODE_TO_HA.items()} WIND = "wind" @@ -362,6 +378,7 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Define a SmartThings Air Conditioner.""" _attr_name = None + _attr_translation_key = "air_conditioner" def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" @@ -582,9 +599,7 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, Attribute.AC_OPTIONAL_MODE, ) - # Return the mode if it is in the supported modes - if self._attr_preset_modes and mode in self._attr_preset_modes: - return mode + return PRESET_MODE_TO_HA.get(mode) return None def _determine_preset_modes(self) -> list[str] | None: @@ -594,8 +609,16 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, Attribute.SUPPORTED_AC_OPTIONAL_MODE, ) - if supported_modes: - return supported_modes + modes = [] + for mode in supported_modes: + if (ha_mode := PRESET_MODE_TO_HA.get(mode)) is not None: + modes.append(ha_mode) + else: + _LOGGER.warning( + "Unknown preset mode: %s, please report at https://github.com/home-assistant/core/issues", + mode, + ) + return modes return None async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -603,7 +626,7 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): await self.execute_device_command( Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, Command.SET_AC_OPTIONAL_MODE, - argument=preset_mode, + argument=HA_MODE_TO_PRESET_MODE[preset_mode], ) def _determine_hvac_modes(self) -> list[HVACMode]: diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 0c9cc394fb39..244324bb1b4c 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -78,6 +78,21 @@ "name": "[%key:common::action::stop%]" } }, + "climate": { + "air_conditioner": { + "state_attributes": { + "preset_mode": { + "state": { + "wind_free": "WindFree", + "wind_free_sleep": "WindFree sleep", + "quiet": "Quiet", + "long_wind": "Long wind", + "smart": "Smart" + } + } + } + } + }, "event": { "button": { "state": { diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index b28a7c761f4c..c45417122e92 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -99,7 +99,6 @@ def mock_smartthings() -> Generator[AsyncMock]: "aq_sensor_3_ikea", "da_ac_airsensor_01001", "da_ac_rac_000001", - "da_ac_rac_000002", "da_ac_rac_000003", "da_ac_rac_100001", "da_ac_rac_01001", diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000002.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000002.json deleted file mode 100644 index 1dce4ae52614..000000000000 --- a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000002.json +++ /dev/null @@ -1,886 +0,0 @@ -{ - "components": { - "1": { - "relativeHumidityMeasurement": { - "humidity": { - "value": 0, - "unit": "%", - "timestamp": "2021-04-06T16:43:35.291Z" - } - }, - "custom.airConditionerOdorController": { - "airConditionerOdorControllerProgress": { - "value": null, - "timestamp": "2021-04-08T04:11:38.269Z" - }, - "airConditionerOdorControllerState": { - "value": null, - "timestamp": "2021-04-08T04:11:38.269Z" - } - }, - "custom.thermostatSetpointControl": { - "minimumSetpoint": { - "value": null, - "timestamp": "2021-04-08T04:04:19.901Z" - }, - "maximumSetpoint": { - "value": null, - "timestamp": "2021-04-08T04:04:19.901Z" - } - }, - "airConditionerMode": { - "availableAcModes": { - "value": null - }, - "supportedAcModes": { - "value": null, - "timestamp": "2021-04-08T03:50:50.930Z" - }, - "airConditionerMode": { - "value": null, - "timestamp": "2021-04-08T03:50:50.930Z" - } - }, - "custom.spiMode": { - "spiMode": { - "value": null, - "timestamp": "2021-04-06T16:57:57.686Z" - } - }, - "airQualitySensor": { - "airQuality": { - "value": null, - "unit": "CAQI", - "timestamp": "2021-04-06T16:57:57.602Z" - } - }, - "custom.airConditionerOptionalMode": { - "supportedAcOptionalMode": { - "value": null, - "timestamp": "2021-04-06T16:57:57.659Z" - }, - "acOptionalMode": { - "value": null, - "timestamp": "2021-04-06T16:57:57.659Z" - } - }, - "switch": { - "switch": { - "value": null, - "timestamp": "2021-04-06T16:44:10.518Z" - } - }, - "custom.airConditionerTropicalNightMode": { - "acTropicalNightModeLevel": { - "value": null, - "timestamp": "2021-04-06T16:44:10.498Z" - } - }, - "ocf": { - "st": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "mndt": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "mnfv": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "mnhw": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "di": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "mnsl": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "dmv": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "n": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "mnmo": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "vid": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "mnmn": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "mnml": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "mnpv": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "mnos": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "pi": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "icv": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - } - }, - "airConditionerFanMode": { - "fanMode": { - "value": null, - "timestamp": "2021-04-06T16:44:10.381Z" - }, - "supportedAcFanModes": { - "value": ["auto", "low", "medium", "high", "turbo"], - "timestamp": "2024-09-10T10:26:28.605Z" - }, - "availableAcFanModes": { - "value": null - } - }, - "custom.disabledCapabilities": { - "disabledCapabilities": { - "value": [ - "remoteControlStatus", - "airQualitySensor", - "dustSensor", - "odorSensor", - "veryFineDustSensor", - "custom.dustFilter", - "custom.deodorFilter", - "custom.deviceReportStateConfiguration", - "audioVolume", - "custom.autoCleaningMode", - "custom.airConditionerTropicalNightMode", - "custom.airConditionerOdorController", - "demandResponseLoadControl", - "relativeHumidityMeasurement" - ], - "timestamp": "2024-09-10T10:26:28.605Z" - } - }, - "fanOscillationMode": { - "supportedFanOscillationModes": { - "value": null, - "timestamp": "2021-04-06T16:44:10.325Z" - }, - "availableFanOscillationModes": { - "value": null - }, - "fanOscillationMode": { - "value": "fixed", - "timestamp": "2025-02-08T00:44:53.247Z" - } - }, - "temperatureMeasurement": { - "temperatureRange": { - "value": null - }, - "temperature": { - "value": null, - "timestamp": "2021-04-06T16:44:10.373Z" - } - }, - "dustSensor": { - "dustLevel": { - "value": null, - "unit": "\u03bcg/m^3", - "timestamp": "2021-04-06T16:44:10.122Z" - }, - "fineDustLevel": { - "value": null, - "unit": "\u03bcg/m^3", - "timestamp": "2021-04-06T16:44:10.122Z" - } - }, - "custom.deviceReportStateConfiguration": { - "reportStateRealtimePeriod": { - "value": null, - "timestamp": "2021-04-06T16:44:09.800Z" - }, - "reportStateRealtime": { - "value": null, - "timestamp": "2021-04-06T16:44:09.800Z" - }, - "reportStatePeriod": { - "value": null, - "timestamp": "2021-04-06T16:44:09.800Z" - } - }, - "thermostatCoolingSetpoint": { - "coolingSetpointRange": { - "value": null - }, - "coolingSetpoint": { - "value": null, - "timestamp": "2021-04-06T16:43:59.136Z" - } - }, - "demandResponseLoadControl": { - "drlcStatus": { - "value": null, - "timestamp": "2021-04-06T16:43:54.748Z" - } - }, - "audioVolume": { - "volume": { - "value": null, - "unit": "%", - "timestamp": "2021-04-06T16:43:53.541Z" - } - }, - "powerConsumptionReport": { - "powerConsumption": { - "value": null, - "timestamp": "2021-04-06T16:43:53.364Z" - } - }, - "custom.autoCleaningMode": { - "supportedAutoCleaningModes": { - "value": null - }, - "timedCleanDuration": { - "value": null - }, - "operatingState": { - "value": null - }, - "timedCleanDurationRange": { - "value": null - }, - "supportedOperatingStates": { - "value": null - }, - "progress": { - "value": null - }, - "autoCleaningMode": { - "value": null, - "timestamp": "2021-04-06T16:43:53.344Z" - } - }, - "custom.dustFilter": { - "dustFilterUsageStep": { - "value": null, - "timestamp": "2021-04-06T16:43:39.145Z" - }, - "dustFilterUsage": { - "value": null, - "timestamp": "2021-04-06T16:43:39.145Z" - }, - "dustFilterLastResetDate": { - "value": null, - "timestamp": "2021-04-06T16:43:39.145Z" - }, - "dustFilterStatus": { - "value": null, - "timestamp": "2021-04-06T16:43:39.145Z" - }, - "dustFilterCapacity": { - "value": null, - "timestamp": "2021-04-06T16:43:39.145Z" - }, - "dustFilterResetType": { - "value": null, - "timestamp": "2021-04-06T16:43:39.145Z" - } - }, - "odorSensor": { - "odorLevel": { - "value": null, - "timestamp": "2021-04-06T16:43:38.992Z" - } - }, - "remoteControlStatus": { - "remoteControlEnabled": { - "value": null, - "timestamp": "2021-04-06T16:43:39.097Z" - } - }, - "custom.deodorFilter": { - "deodorFilterCapacity": { - "value": null, - "timestamp": "2021-04-06T16:43:39.118Z" - }, - "deodorFilterLastResetDate": { - "value": null, - "timestamp": "2021-04-06T16:43:39.118Z" - }, - "deodorFilterStatus": { - "value": null, - "timestamp": "2021-04-06T16:43:39.118Z" - }, - "deodorFilterResetType": { - "value": null, - "timestamp": "2021-04-06T16:43:39.118Z" - }, - "deodorFilterUsage": { - "value": null, - "timestamp": "2021-04-06T16:43:39.118Z" - }, - "deodorFilterUsageStep": { - "value": null, - "timestamp": "2021-04-06T16:43:39.118Z" - } - }, - "custom.energyType": { - "energyType": { - "value": null, - "timestamp": "2021-04-06T16:43:38.843Z" - }, - "energySavingSupport": { - "value": null - }, - "drMaxDuration": { - "value": null - }, - "energySavingLevel": { - "value": null - }, - "energySavingInfo": { - "value": null - }, - "supportedEnergySavingLevels": { - "value": null - }, - "energySavingOperation": { - "value": null - }, - "notificationTemplateID": { - "value": null - }, - "energySavingOperationSupport": { - "value": null - } - }, - "veryFineDustSensor": { - "veryFineDustLevel": { - "value": null, - "unit": "\u03bcg/m^3", - "timestamp": "2021-04-06T16:43:38.529Z" - } - } - }, - "main": { - "relativeHumidityMeasurement": { - "humidity": { - "value": 60, - "unit": "%", - "timestamp": "2024-12-30T13:10:23.759Z" - } - }, - "custom.airConditionerOdorController": { - "airConditionerOdorControllerProgress": { - "value": null, - "timestamp": "2021-04-06T16:43:37.555Z" - }, - "airConditionerOdorControllerState": { - "value": null, - "timestamp": "2021-04-06T16:43:37.555Z" - } - }, - "custom.thermostatSetpointControl": { - "minimumSetpoint": { - "value": 16, - "unit": "C", - "timestamp": "2025-01-08T06:30:58.307Z" - }, - "maximumSetpoint": { - "value": 30, - "unit": "C", - "timestamp": "2024-09-10T10:26:28.781Z" - } - }, - "airConditionerMode": { - "availableAcModes": { - "value": null - }, - "supportedAcModes": { - "value": ["cool", "dry", "wind", "auto", "heat"], - "timestamp": "2024-09-10T10:26:28.781Z" - }, - "airConditionerMode": { - "value": "heat", - "timestamp": "2025-02-09T09:14:39.642Z" - } - }, - "custom.spiMode": { - "spiMode": { - "value": "off", - "timestamp": "2025-02-09T09:14:39.642Z" - } - }, - "samsungce.dongleSoftwareInstallation": { - "status": { - "value": "completed", - "timestamp": "2021-12-29T01:36:51.289Z" - } - }, - "samsungce.deviceIdentification": { - "micomAssayCode": { - "value": null - }, - "modelName": { - "value": null - }, - "serialNumber": { - "value": null - }, - "serialNumberExtra": { - "value": null - }, - "modelClassificationCode": { - "value": null - }, - "description": { - "value": null - }, - "releaseYear": { - "value": null - }, - "binaryId": { - "value": "ARTIK051_KRAC_18K", - "timestamp": "2025-02-08T00:44:53.855Z" - } - }, - "airQualitySensor": { - "airQuality": { - "value": null, - "unit": "CAQI", - "timestamp": "2021-04-06T16:43:37.208Z" - } - }, - "custom.airConditionerOptionalMode": { - "supportedAcOptionalMode": { - "value": [ - "off", - "sleep", - "quiet", - "speed", - "windFree", - "windFreeSleep" - ], - "timestamp": "2024-09-10T10:26:28.781Z" - }, - "acOptionalMode": { - "value": "windFree", - "timestamp": "2025-02-09T09:14:39.642Z" - } - }, - "switch": { - "switch": { - "value": "off", - "timestamp": "2025-02-09T16:37:54.072Z" - } - }, - "custom.airConditionerTropicalNightMode": { - "acTropicalNightModeLevel": { - "value": 0, - "timestamp": "2025-02-09T09:14:39.642Z" - } - }, - "ocf": { - "st": { - "value": null, - "timestamp": "2021-04-06T16:43:35.933Z" - }, - "mndt": { - "value": null, - "timestamp": "2021-04-06T16:43:35.912Z" - }, - "mnfv": { - "value": "0.1.0", - "timestamp": "2024-09-10T10:26:28.552Z" - }, - "mnhw": { - "value": "1.0", - "timestamp": "2024-09-10T10:26:28.552Z" - }, - "di": { - "value": "13549124-3320-4fda-8e5c-3f363e043034", - "timestamp": "2024-09-10T10:26:28.552Z" - }, - "mnsl": { - "value": null, - "timestamp": "2021-04-06T16:43:35.803Z" - }, - "dmv": { - "value": "res.1.1.0,sh.1.1.0", - "timestamp": "2024-09-10T10:26:28.552Z" - }, - "n": { - "value": "[room a/c] Samsung", - "timestamp": "2024-09-10T10:26:28.552Z" - }, - "mnmo": { - "value": "ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000", - "timestamp": "2024-09-10T10:26:28.781Z" - }, - "vid": { - "value": "DA-AC-RAC-000001", - "timestamp": "2024-09-10T10:26:28.552Z" - }, - "mnmn": { - "value": "Samsung Electronics", - "timestamp": "2024-09-10T10:26:28.552Z" - }, - "mnml": { - "value": "http://www.samsung.com", - "timestamp": "2024-09-10T10:26:28.552Z" - }, - "mnpv": { - "value": "0G3MPDCKA00010E", - "timestamp": "2024-09-10T10:26:28.552Z" - }, - "mnos": { - "value": "TizenRT2.0", - "timestamp": "2024-09-10T10:26:28.552Z" - }, - "pi": { - "value": "13549124-3320-4fda-8e5c-3f363e043034", - "timestamp": "2024-09-10T10:26:28.552Z" - }, - "icv": { - "value": "core.1.1.0", - "timestamp": "2024-09-10T10:26:28.552Z" - } - }, - "airConditionerFanMode": { - "fanMode": { - "value": "low", - "timestamp": "2025-02-09T09:14:39.249Z" - }, - "supportedAcFanModes": { - "value": ["auto", "low", "medium", "high", "turbo"], - "timestamp": "2025-02-09T09:14:39.249Z" - }, - "availableAcFanModes": { - "value": null - } - }, - "custom.disabledCapabilities": { - "disabledCapabilities": { - "value": [ - "remoteControlStatus", - "airQualitySensor", - "dustSensor", - "veryFineDustSensor", - "custom.dustFilter", - "custom.deodorFilter", - "custom.deviceReportStateConfiguration", - "samsungce.dongleSoftwareInstallation", - "demandResponseLoadControl", - "custom.airConditionerOdorController" - ], - "timestamp": "2025-02-09T09:14:39.642Z" - } - }, - "samsungce.driverVersion": { - "versionNumber": { - "value": 24070101, - "timestamp": "2024-09-04T06:35:09.557Z" - } - }, - "fanOscillationMode": { - "supportedFanOscillationModes": { - "value": null, - "timestamp": "2021-04-06T16:43:35.782Z" - }, - "availableFanOscillationModes": { - "value": null - }, - "fanOscillationMode": { - "value": "fixed", - "timestamp": "2025-02-09T09:14:39.249Z" - } - }, - "temperatureMeasurement": { - "temperatureRange": { - "value": null - }, - "temperature": { - "value": 25, - "unit": "C", - "timestamp": "2025-02-09T16:33:29.164Z" - } - }, - "dustSensor": { - "dustLevel": { - "value": null, - "unit": "\u03bcg/m^3", - "timestamp": "2021-04-06T16:43:35.665Z" - }, - "fineDustLevel": { - "value": null, - "unit": "\u03bcg/m^3", - "timestamp": "2021-04-06T16:43:35.665Z" - } - }, - "custom.deviceReportStateConfiguration": { - "reportStateRealtimePeriod": { - "value": null, - "timestamp": "2021-04-06T16:43:35.643Z" - }, - "reportStateRealtime": { - "value": null, - "timestamp": "2021-04-06T16:43:35.643Z" - }, - "reportStatePeriod": { - "value": null, - "timestamp": "2021-04-06T16:43:35.643Z" - } - }, - "thermostatCoolingSetpoint": { - "coolingSetpointRange": { - "value": null - }, - "coolingSetpoint": { - "value": 25, - "unit": "C", - "timestamp": "2025-02-09T09:15:11.608Z" - } - }, - "custom.disabledComponents": { - "disabledComponents": { - "value": ["1"], - "timestamp": "2025-02-09T09:14:39.642Z" - } - }, - "demandResponseLoadControl": { - "drlcStatus": { - "value": { - "drlcType": 1, - "drlcLevel": -1, - "start": "1970-01-01T00:00:00Z", - "duration": 0, - "override": false - }, - "timestamp": "2024-09-10T10:26:28.781Z" - } - }, - "audioVolume": { - "volume": { - "value": 100, - "unit": "%", - "timestamp": "2025-02-09T09:14:39.642Z" - } - }, - "powerConsumptionReport": { - "powerConsumption": { - "value": { - "energy": 2247300, - "deltaEnergy": 400, - "power": 0, - "powerEnergy": 0.0, - "persistedEnergy": 2247300, - "energySaved": 0, - "start": "2025-02-09T15:45:29Z", - "end": "2025-02-09T16:15:33Z" - }, - "timestamp": "2025-02-09T16:15:33.639Z" - } - }, - "custom.autoCleaningMode": { - "supportedAutoCleaningModes": { - "value": null - }, - "timedCleanDuration": { - "value": null - }, - "operatingState": { - "value": null - }, - "timedCleanDurationRange": { - "value": null - }, - "supportedOperatingStates": { - "value": null - }, - "progress": { - "value": null - }, - "autoCleaningMode": { - "value": "off", - "timestamp": "2025-02-09T09:14:39.642Z" - } - }, - "refresh": {}, - "execute": { - "data": { - "value": { - "payload": { - "rt": ["oic.r.temperature"], - "if": ["oic.if.baseline", "oic.if.a"], - "range": [16.0, 30.0], - "units": "C", - "temperature": 22.0 - } - }, - "data": { - "href": "/temperature/desired/0" - }, - "timestamp": "2023-07-19T03:07:43.270Z" - } - }, - "samsungce.selfCheck": { - "result": { - "value": null - }, - "supportedActions": { - "value": ["start"], - "timestamp": "2024-09-04T06:35:09.557Z" - }, - "progress": { - "value": null - }, - "errors": { - "value": [], - "timestamp": "2025-02-08T00:44:53.349Z" - }, - "status": { - "value": "ready", - "timestamp": "2025-02-08T00:44:53.549Z" - } - }, - "custom.dustFilter": { - "dustFilterUsageStep": { - "value": null, - "timestamp": "2021-04-06T16:43:35.527Z" - }, - "dustFilterUsage": { - "value": null, - "timestamp": "2021-04-06T16:43:35.527Z" - }, - "dustFilterLastResetDate": { - "value": null, - "timestamp": "2021-04-06T16:43:35.527Z" - }, - "dustFilterStatus": { - "value": null, - "timestamp": "2021-04-06T16:43:35.527Z" - }, - "dustFilterCapacity": { - "value": null, - "timestamp": "2021-04-06T16:43:35.527Z" - }, - "dustFilterResetType": { - "value": null, - "timestamp": "2021-04-06T16:43:35.527Z" - } - }, - "remoteControlStatus": { - "remoteControlEnabled": { - "value": null, - "timestamp": "2021-04-06T16:43:35.379Z" - } - }, - "custom.deodorFilter": { - "deodorFilterCapacity": { - "value": null, - "timestamp": "2021-04-06T16:43:35.502Z" - }, - "deodorFilterLastResetDate": { - "value": null, - "timestamp": "2021-04-06T16:43:35.502Z" - }, - "deodorFilterStatus": { - "value": null, - "timestamp": "2021-04-06T16:43:35.502Z" - }, - "deodorFilterResetType": { - "value": null, - "timestamp": "2021-04-06T16:43:35.502Z" - }, - "deodorFilterUsage": { - "value": null, - "timestamp": "2021-04-06T16:43:35.502Z" - }, - "deodorFilterUsageStep": { - "value": null, - "timestamp": "2021-04-06T16:43:35.502Z" - } - }, - "custom.energyType": { - "energyType": { - "value": "1.0", - "timestamp": "2024-09-10T10:26:28.781Z" - }, - "energySavingSupport": { - "value": false, - "timestamp": "2021-12-29T07:29:17.526Z" - }, - "drMaxDuration": { - "value": null - }, - "energySavingLevel": { - "value": null - }, - "energySavingInfo": { - "value": null - }, - "supportedEnergySavingLevels": { - "value": null - }, - "energySavingOperation": { - "value": null - }, - "notificationTemplateID": { - "value": null - }, - "energySavingOperationSupport": { - "value": null - } - }, - "samsungce.softwareUpdate": { - "targetModule": { - "value": null - }, - "otnDUID": { - "value": "43CEZFTFFL7Z2", - "timestamp": "2025-02-08T00:44:53.855Z" - }, - "lastUpdatedDate": { - "value": null - }, - "availableModules": { - "value": [], - "timestamp": "2025-02-08T00:44:53.855Z" - }, - "newVersionAvailable": { - "value": false, - "timestamp": "2025-02-08T00:44:53.855Z" - }, - "operatingState": { - "value": null - }, - "progress": { - "value": null - } - }, - "veryFineDustSensor": { - "veryFineDustLevel": { - "value": null, - "unit": "\u03bcg/m^3", - "timestamp": "2021-04-06T16:43:35.363Z" - } - } - } - } -} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_000002.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_000002.json deleted file mode 100644 index f14341897601..000000000000 --- a/tests/components/smartthings/fixtures/devices/da_ac_rac_000002.json +++ /dev/null @@ -1,303 +0,0 @@ -{ - "items": [ - { - "deviceId": "13549124-3320-4fda-8e5c-3f363e043034", - "name": "[room a/c] Samsung", - "label": "AC Office Granit", - "manufacturerName": "Samsung Electronics", - "presentationId": "DA-AC-RAC-000001", - "deviceManufacturerCode": "Samsung Electronics", - "locationId": "58d3fd7c-c512-4da3-b500-ef269382756c", - "ownerId": "f9a28d7c-1ed5-d9e9-a81c-18971ec081db", - "roomId": "7715151d-0314-457a-a82c-5ce48900e065", - "deviceTypeName": "Samsung OCF Air Conditioner", - "components": [ - { - "id": "main", - "label": "main", - "capabilities": [ - { - "id": "ocf", - "version": 1 - }, - { - "id": "switch", - "version": 1 - }, - { - "id": "airConditionerMode", - "version": 1 - }, - { - "id": "airConditionerFanMode", - "version": 1 - }, - { - "id": "fanOscillationMode", - "version": 1 - }, - { - "id": "airQualitySensor", - "version": 1 - }, - { - "id": "temperatureMeasurement", - "version": 1 - }, - { - "id": "thermostatCoolingSetpoint", - "version": 1 - }, - { - "id": "relativeHumidityMeasurement", - "version": 1 - }, - { - "id": "dustSensor", - "version": 1 - }, - { - "id": "veryFineDustSensor", - "version": 1 - }, - { - "id": "audioVolume", - "version": 1 - }, - { - "id": "remoteControlStatus", - "version": 1 - }, - { - "id": "powerConsumptionReport", - "version": 1 - }, - { - "id": "demandResponseLoadControl", - "version": 1 - }, - { - "id": "refresh", - "version": 1 - }, - { - "id": "execute", - "version": 1 - }, - { - "id": "custom.spiMode", - "version": 1 - }, - { - "id": "custom.thermostatSetpointControl", - "version": 1 - }, - { - "id": "custom.airConditionerOptionalMode", - "version": 1 - }, - { - "id": "custom.airConditionerTropicalNightMode", - "version": 1 - }, - { - "id": "custom.autoCleaningMode", - "version": 1 - }, - { - "id": "custom.deviceReportStateConfiguration", - "version": 1 - }, - { - "id": "custom.energyType", - "version": 1 - }, - { - "id": "custom.dustFilter", - "version": 1 - }, - { - "id": "custom.airConditionerOdorController", - "version": 1 - }, - { - "id": "custom.deodorFilter", - "version": 1 - }, - { - "id": "custom.disabledComponents", - "version": 1 - }, - { - "id": "custom.disabledCapabilities", - "version": 1 - }, - { - "id": "samsungce.deviceIdentification", - "version": 1 - }, - { - "id": "samsungce.dongleSoftwareInstallation", - "version": 1 - }, - { - "id": "samsungce.softwareUpdate", - "version": 1 - }, - { - "id": "samsungce.selfCheck", - "version": 1 - }, - { - "id": "samsungce.driverVersion", - "version": 1 - } - ], - "categories": [ - { - "name": "AirConditioner", - "categoryType": "manufacturer" - } - ] - }, - { - "id": "1", - "label": "1", - "capabilities": [ - { - "id": "switch", - "version": 1 - }, - { - "id": "airConditionerMode", - "version": 1 - }, - { - "id": "airConditionerFanMode", - "version": 1 - }, - { - "id": "fanOscillationMode", - "version": 1 - }, - { - "id": "temperatureMeasurement", - "version": 1 - }, - { - "id": "thermostatCoolingSetpoint", - "version": 1 - }, - { - "id": "relativeHumidityMeasurement", - "version": 1 - }, - { - "id": "airQualitySensor", - "version": 1 - }, - { - "id": "dustSensor", - "version": 1 - }, - { - "id": "veryFineDustSensor", - "version": 1 - }, - { - "id": "odorSensor", - "version": 1 - }, - { - "id": "remoteControlStatus", - "version": 1 - }, - { - "id": "audioVolume", - "version": 1 - }, - { - "id": "custom.thermostatSetpointControl", - "version": 1 - }, - { - "id": "custom.autoCleaningMode", - "version": 1 - }, - { - "id": "custom.airConditionerTropicalNightMode", - "version": 1 - }, - { - "id": "custom.disabledCapabilities", - "version": 1 - }, - { - "id": "ocf", - "version": 1 - }, - { - "id": "powerConsumptionReport", - "version": 1 - }, - { - "id": "demandResponseLoadControl", - "version": 1 - }, - { - "id": "custom.spiMode", - "version": 1 - }, - { - "id": "custom.airConditionerOptionalMode", - "version": 1 - }, - { - "id": "custom.deviceReportStateConfiguration", - "version": 1 - }, - { - "id": "custom.energyType", - "version": 1 - }, - { - "id": "custom.dustFilter", - "version": 1 - }, - { - "id": "custom.airConditionerOdorController", - "version": 1 - }, - { - "id": "custom.deodorFilter", - "version": 1 - } - ], - "categories": [ - { - "name": "Other", - "categoryType": "manufacturer" - } - ] - } - ], - "createTime": "2021-04-06T16:43:34.753Z", - "profile": { - "id": "60fbc713-8da5-315d-b31a-6d6dcde4be7b" - }, - "ocf": { - "ocfDeviceType": "x.com.st.d.sensor.light", - "manufacturerName": "Samsung Electronics", - "vendorId": "VD-Sensor.Light-2023", - "lastSignupTime": "2025-01-08T02:32:04.631093137Z", - "transferCandidate": false, - "additionalAuthCodeRequired": false - }, - "type": "OCF", - "restrictionTier": 0, - "allowed": [], - "executionContext": "CLOUD" - } - ], - "_links": {} -} diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 6976371376c3..293aa961ca74 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -36,7 +36,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'air_conditioner', 'unique_id': 'bf53a150-f8a4-45d1-aac4-86252475d551_main', 'unit_of_measurement': None, }) @@ -153,10 +153,10 @@ 'max_temp': 35, 'min_temp': 7, 'preset_modes': list([ - 'off', - 'windFree', - 'longWind', - 'speed', + 'none', + 'wind_free', + 'long_wind', + 'boost', 'quiet', 'sleep', ]), @@ -191,7 +191,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'air_conditioner', 'unique_id': '23c6d296-4656-20d8-f6eb-2ff13e041753_main', 'unit_of_measurement': None, }) @@ -222,12 +222,12 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'preset_mode': 'off', + 'preset_mode': 'none', 'preset_modes': list([ - 'off', - 'windFree', - 'longWind', - 'speed', + 'none', + 'wind_free', + 'long_wind', + 'boost', 'quiet', 'sleep', ]), @@ -341,8 +341,8 @@ 'max_temp': 35, 'min_temp': 7, 'preset_modes': list([ - 'off', - 'windFree', + 'none', + 'wind_free', ]), 'swing_modes': None, }), @@ -370,7 +370,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'air_conditioner', 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main', 'unit_of_measurement': None, }) @@ -402,121 +402,10 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'preset_mode': 'windFree', + 'preset_mode': 'wind_free', 'preset_modes': list([ - 'off', - 'windFree', - ]), - 'supported_features': , - 'swing_mode': 'off', - 'swing_modes': None, - 'temperature': 25, - }), - 'context': , - 'entity_id': 'climate.ac_office_granit', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_all_entities[da_ac_rac_000002][climate.ac_office_granit-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'fan_modes': list([ - 'auto', - 'low', - 'medium', - 'high', - 'turbo', - ]), - 'hvac_modes': list([ - , - , - , - , - , - , - ]), - 'max_temp': 35, - 'min_temp': 7, - 'preset_modes': list([ - 'off', - 'sleep', - 'quiet', - 'speed', - 'windFree', - 'windFreeSleep', - ]), - 'swing_modes': None, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'climate', - 'entity_category': None, - 'entity_id': 'climate.ac_office_granit', - 'has_entity_name': 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': '13549124-3320-4fda-8e5c-3f363e043034_main', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ac_rac_000002][climate.ac_office_granit-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'current_temperature': 25, - 'drlc_status_duration': 0, - 'drlc_status_level': -1, - 'drlc_status_override': False, - 'drlc_status_start': '1970-01-01T00:00:00Z', - 'fan_mode': 'low', - 'fan_modes': list([ - 'auto', - 'low', - 'medium', - 'high', - 'turbo', - ]), - 'friendly_name': 'AC Office Granit', - 'hvac_modes': list([ - , - , - , - , - , - , - ]), - 'max_temp': 35, - 'min_temp': 7, - 'preset_mode': 'windFree', - 'preset_modes': list([ - 'off', - 'sleep', - 'quiet', - 'speed', - 'windFree', - 'windFreeSleep', + 'none', + 'wind_free', ]), 'supported_features': , 'swing_mode': 'off', @@ -554,13 +443,13 @@ 'max_temp': 35, 'min_temp': 7, 'preset_modes': list([ - 'off', + 'none', 'sleep', 'quiet', 'smart', - 'speed', - 'windFree', - 'windFreeSleep', + 'boost', + 'wind_free', + 'wind_free_sleep', ]), 'swing_modes': list([ 'off', @@ -593,7 +482,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'air_conditioner', 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main', 'unit_of_measurement': None, }) @@ -622,15 +511,15 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'preset_mode': 'off', + 'preset_mode': 'none', 'preset_modes': list([ - 'off', + 'none', 'sleep', 'quiet', 'smart', - 'speed', - 'windFree', - 'windFreeSleep', + 'boost', + 'wind_free', + 'wind_free_sleep', ]), 'supported_features': , 'swing_mode': 'off', @@ -674,13 +563,13 @@ 'max_temp': 35, 'min_temp': 7, 'preset_modes': list([ - 'off', + 'none', 'sleep', 'quiet', 'smart', - 'speed', - 'windFree', - 'windFreeSleep', + 'boost', + 'wind_free', + 'wind_free_sleep', ]), 'swing_modes': list([ 'off', @@ -713,7 +602,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'air_conditioner', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main', 'unit_of_measurement': None, }) @@ -745,15 +634,15 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'preset_mode': 'off', + 'preset_mode': 'none', 'preset_modes': list([ - 'off', + 'none', 'sleep', 'quiet', 'smart', - 'speed', - 'windFree', - 'windFreeSleep', + 'boost', + 'wind_free', + 'wind_free_sleep', ]), 'supported_features': , 'swing_mode': 'off', @@ -820,7 +709,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'air_conditioner', 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main', 'unit_of_measurement': None, }) diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 0de7bcc5bf0c..5cd56c316839 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -436,37 +436,6 @@ 'via_device_id': None, }) # --- -# name: test_devices[da_ac_rac_000002] - DeviceRegistryEntrySnapshot({ - 'area_id': 'theater', - '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', - '13549124-3320-4fda-8e5c-3f363e043034', - ), - }), - 'labels': set({ - }), - 'manufacturer': 'Samsung Electronics', - 'model': None, - 'model_id': None, - 'name': 'AC Office Granit', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- # name: test_devices[da_ac_rac_000003] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 78c5ba9bed15..9e83fdacab91 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2509,446 +2509,6 @@ 'state': '100', }) # --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_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.ac_office_granit_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': '13549124-3320-4fda-8e5c-3f363e043034_main_powerConsumptionReport_powerConsumption_energy_meter', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_energy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'AC Office Granit Energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2247.3', - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_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.ac_office_granit_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': '13549124-3320-4fda-8e5c-3f363e043034_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_energy_difference-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'AC Office Granit Energy difference', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_energy_difference', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.4', - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_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.ac_office_granit_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': '13549124-3320-4fda-8e5c-3f363e043034_main_powerConsumptionReport_powerConsumption_energySaved_meter', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_energy_saved-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'AC Office Granit Energy saved', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_energy_saved', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_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.ac_office_granit_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': '13549124-3320-4fda-8e5c-3f363e043034_main_relativeHumidityMeasurement_humidity_humidity', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_humidity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'friendly_name': 'AC Office Granit Humidity', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '60', - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_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.ac_office_granit_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': '13549124-3320-4fda-8e5c-3f363e043034_main_powerConsumptionReport_powerConsumption_power_meter', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'AC Office Granit Power', - 'power_consumption_end': '2025-02-09T16:15:33Z', - 'power_consumption_start': '2025-02-09T15:45:29Z', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_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.ac_office_granit_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': '13549124-3320-4fda-8e5c-3f363e043034_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_power_energy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'AC Office Granit Power energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_power_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_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.ac_office_granit_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': '13549124-3320-4fda-8e5c-3f363e043034_main_temperatureMeasurement_temperature_temperature', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'AC Office Granit Temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25', - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_volume-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_volume', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Volume', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'audio_volume', - 'unique_id': '13549124-3320-4fda-8e5c-3f363e043034_main_audioVolume_volume_volume', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_volume-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'AC Office Granit Volume', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_volume', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '100', - }) -# --- # name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index e1a8129c873b..d27bd042b119 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -23,6 +23,9 @@ from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN, + PRESET_BOOST, + PRESET_NONE, + PRESET_SLEEP, SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, @@ -441,86 +444,43 @@ async def test_ac_set_swing_mode( ) -@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000002"]) +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000003"]) @pytest.mark.parametrize( - "mode", ["off", "sleep", "quiet", "speed", "windFree", "windFreeSleep"] + ("mode", "expected_mode"), + [ + (PRESET_NONE, "off"), + (PRESET_SLEEP, "sleep"), + ("quiet", "quiet"), + (PRESET_BOOST, "speed"), + ("wind_free", "windFree"), + ("wind_free_sleep", "windFreeSleep"), + ], ) async def test_ac_set_preset_mode( hass: HomeAssistant, devices: AsyncMock, mode: str, + expected_mode: str, mock_config_entry: MockConfigEntry, ) -> None: """Test setting and retrieving AC preset modes.""" await setup_integration(hass, mock_config_entry) - # Mock supported preset modes - set_attribute_value( - devices, - Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, - Attribute.SUPPORTED_AC_OPTIONAL_MODE, - ["off", "sleep", "quiet", "speed", "windFree", "windFreeSleep"], - ) - await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_PRESET_MODE: mode}, + {ATTR_ENTITY_ID: "climate.office_airfree", ATTR_PRESET_MODE: mode}, blocking=True, ) devices.execute_device_command.assert_called_with( - "13549124-3320-4fda-8e5c-3f363e043034", + "c76d6f38-1b7f-13dd-37b5-db18d5272783", Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, Command.SET_AC_OPTIONAL_MODE, MAIN, - argument=mode, + argument=expected_mode, ) -@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000002"]) -@pytest.mark.parametrize( - "mode", ["off", "sleep", "quiet", "speed", "windFree", "windFreeSleep"] -) -async def test_ac_get_preset_mode( - hass: HomeAssistant, - devices: AsyncMock, - mode: str, - mock_config_entry: MockConfigEntry, -) -> None: - """Test setting and retrieving AC preset modes.""" - await setup_integration(hass, mock_config_entry) - - # Mock supported preset modes - set_attribute_value( - devices, - Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, - Attribute.SUPPORTED_AC_OPTIONAL_MODE, - ["off", "sleep", "quiet", "speed", "windFree", "windFreeSleep"], - ) - - # Mock the current preset mode to simulate the device state - set_attribute_value( - devices, - Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, - Attribute.AC_OPTIONAL_MODE, - mode, - ) - - # Trigger an update to refresh the state - await trigger_update( - hass, - devices, - "13549124-3320-4fda-8e5c-3f363e043034", - Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, - Attribute.AC_OPTIONAL_MODE, - mode, - ) - - # Verify the preset mode is correctly reflected in the entity state - state = hass.states.get("climate.ac_office_granit") - assert state.attributes[ATTR_PRESET_MODE] == mode - - @pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) async def test_ac_state_update( hass: HomeAssistant, From 711a56db2fb73e3b88d11cab9e4e8421556d245b Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 24 Sep 2025 11:56:56 +0200 Subject: [PATCH 101/189] Add dynamic devices management for UptimeRobot (#152139) --- .../components/uptimerobot/binary_sensor.py | 34 +++++++++----- .../components/uptimerobot/quality_scale.yaml | 4 +- .../components/uptimerobot/sensor.py | 46 +++++++++++++------ .../components/uptimerobot/switch.py | 34 +++++++++----- .../uptimerobot/test_binary_sensor.py | 41 +++++++++++++++++ tests/components/uptimerobot/test_sensor.py | 41 +++++++++++++++++ tests/components/uptimerobot/test_switch.py | 46 ++++++++++++++++++- 7 files changed, 205 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index e8803b6ad895..52e490222fc7 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -24,17 +24,29 @@ async def async_setup_entry( ) -> None: """Set up the UptimeRobot binary_sensors.""" coordinator = entry.runtime_data - async_add_entities( - UptimeRobotBinarySensor( - coordinator, - BinarySensorEntityDescription( - key=str(monitor.id), - device_class=BinarySensorDeviceClass.CONNECTIVITY, - ), - monitor=monitor, - ) - for monitor in coordinator.data - ) + + known_devices: set[int] = set() + + def _check_device() -> None: + current_devices = {monitor.id for monitor in coordinator.data} + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + UptimeRobotBinarySensor( + coordinator, + BinarySensorEntityDescription( + key=str(monitor.id), + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), + monitor=monitor, + ) + for monitor in coordinator.data + if monitor.id in new_devices + ) + + _check_device() + entry.async_on_unload(coordinator.async_add_listener(_check_device)) class UptimeRobotBinarySensor(UptimeRobotEntity, BinarySensorEntity): diff --git a/homeassistant/components/uptimerobot/quality_scale.yaml b/homeassistant/components/uptimerobot/quality_scale.yaml index 01da4dc5166c..de85152315a2 100644 --- a/homeassistant/components/uptimerobot/quality_scale.yaml +++ b/homeassistant/components/uptimerobot/quality_scale.yaml @@ -57,9 +57,7 @@ rules: docs-supported-functions: done docs-troubleshooting: done docs-use-cases: done - dynamic-devices: - status: todo - comment: create entities on runtime instead of triggering a reload + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: diff --git a/homeassistant/components/uptimerobot/sensor.py b/homeassistant/components/uptimerobot/sensor.py index 3ed97d175081..7a241d6999be 100644 --- a/homeassistant/components/uptimerobot/sensor.py +++ b/homeassistant/components/uptimerobot/sensor.py @@ -33,20 +33,38 @@ async def async_setup_entry( ) -> None: """Set up the UptimeRobot sensors.""" coordinator = entry.runtime_data - async_add_entities( - UptimeRobotSensor( - coordinator, - SensorEntityDescription( - key=str(monitor.id), - entity_category=EntityCategory.DIAGNOSTIC, - device_class=SensorDeviceClass.ENUM, - options=["down", "not_checked_yet", "pause", "seems_down", "up"], - translation_key="monitor_status", - ), - monitor=monitor, - ) - for monitor in coordinator.data - ) + + known_devices: set[int] = set() + + def _check_device() -> None: + current_devices = {monitor.id for monitor in coordinator.data} + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + UptimeRobotSensor( + coordinator, + SensorEntityDescription( + key=str(monitor.id), + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=[ + "down", + "not_checked_yet", + "pause", + "seems_down", + "up", + ], + translation_key="monitor_status", + ), + monitor=monitor, + ) + for monitor in coordinator.data + if monitor.id in new_devices + ) + + _check_device() + entry.async_on_unload(coordinator.async_add_listener(_check_device)) class UptimeRobotSensor(UptimeRobotEntity, SensorEntity): diff --git a/homeassistant/components/uptimerobot/switch.py b/homeassistant/components/uptimerobot/switch.py index 5d80903ed020..531131034ce0 100644 --- a/homeassistant/components/uptimerobot/switch.py +++ b/homeassistant/components/uptimerobot/switch.py @@ -30,17 +30,29 @@ async def async_setup_entry( ) -> None: """Set up the UptimeRobot switches.""" coordinator = entry.runtime_data - async_add_entities( - UptimeRobotSwitch( - coordinator, - SwitchEntityDescription( - key=str(monitor.id), - device_class=SwitchDeviceClass.SWITCH, - ), - monitor=monitor, - ) - for monitor in coordinator.data - ) + + known_devices: set[int] = set() + + def _check_device() -> None: + current_devices = {monitor.id for monitor in coordinator.data} + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + UptimeRobotSwitch( + coordinator, + SwitchEntityDescription( + key=str(monitor.id), + device_class=SwitchDeviceClass.SWITCH, + ), + monitor=monitor, + ) + for monitor in coordinator.data + if monitor.id in new_devices + ) + + _check_device() + entry.async_on_unload(coordinator.async_add_listener(_check_device)) class UptimeRobotSwitch(UptimeRobotEntity, SwitchEntity): diff --git a/tests/components/uptimerobot/test_binary_sensor.py b/tests/components/uptimerobot/test_binary_sensor.py index c214a7d15434..13e4a556d18e 100644 --- a/tests/components/uptimerobot/test_binary_sensor.py +++ b/tests/components/uptimerobot/test_binary_sensor.py @@ -16,6 +16,7 @@ from homeassistant.util import dt as dt_util from .common import ( MOCK_UPTIMEROBOT_MONITOR, UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY, + mock_uptimerobot_api_response, setup_uptimerobot_integration, ) @@ -49,3 +50,43 @@ async def test_unavailable_on_update_failure(hass: HomeAssistant) -> None: assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) assert entity.state == STATE_UNAVAILABLE + + +async def test_binary_sensor_dynamic(hass: HomeAssistant) -> None: + """Test binary_sensor dynamically added.""" + await setup_uptimerobot_integration(hass) + + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_ON + + entity_id_2 = "binary_sensor.test_monitor_2" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response( + data=[ + { + "id": 1234, + "friendly_name": "Test monitor", + "status": 2, + "type": 1, + "url": "http://example.com", + }, + { + "id": 5678, + "friendly_name": "Test monitor 2", + "status": 2, + "type": 1, + "url": "http://example2.com", + }, + ] + ), + ): + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_ON + + assert (entity := hass.states.get(entity_id_2)) + assert entity.state == STATE_ON diff --git a/tests/components/uptimerobot/test_sensor.py b/tests/components/uptimerobot/test_sensor.py index 15e0b0ba1316..26f7432f99cf 100644 --- a/tests/components/uptimerobot/test_sensor.py +++ b/tests/components/uptimerobot/test_sensor.py @@ -14,6 +14,7 @@ from .common import ( MOCK_UPTIMEROBOT_MONITOR, STATE_UP, UPTIMEROBOT_SENSOR_TEST_ENTITY, + mock_uptimerobot_api_response, setup_uptimerobot_integration, ) @@ -53,3 +54,43 @@ async def test_unavailable_on_update_failure(hass: HomeAssistant) -> None: assert (entity := hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY)) is not None assert entity.state == STATE_UNAVAILABLE + + +async def test_sensor_dynamic(hass: HomeAssistant) -> None: + """Test sensor dynamically added.""" + await setup_uptimerobot_integration(hass) + + assert (entity := hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_UP + + entity_id_2 = "sensor.test_monitor_2" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response( + data=[ + { + "id": 1234, + "friendly_name": "Test monitor", + "status": 2, + "type": 1, + "url": "http://example.com", + }, + { + "id": 5678, + "friendly_name": "Test monitor 2", + "status": 2, + "type": 1, + "url": "http://example2.com", + }, + ] + ), + ): + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + + assert (entity := hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_UP + + assert (entity := hass.states.get(entity_id_2)) + assert entity.state == STATE_UP diff --git a/tests/components/uptimerobot/test_switch.py b/tests/components/uptimerobot/test_switch.py index a88158ea7655..e42b46db8616 100644 --- a/tests/components/uptimerobot/test_switch.py +++ b/tests/components/uptimerobot/test_switch.py @@ -6,6 +6,7 @@ import pytest from pyuptimerobot import UptimeRobotAuthenticationException, UptimeRobotException from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.uptimerobot.const import COORDINATOR_UPDATE_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -15,6 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import dt as dt_util from .common import ( MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA, @@ -26,7 +28,7 @@ from .common import ( setup_uptimerobot_integration, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_presentation(hass: HomeAssistant) -> None: @@ -71,7 +73,7 @@ async def test_switch_off(hass: HomeAssistant) -> None: async def test_switch_on(hass: HomeAssistant) -> None: - """Test entity unaviable on update failure.""" + """Test entity unavailable on update failure.""" mock_entry = MockConfigEntry(**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA) mock_entry.add_to_hass(hass) @@ -180,3 +182,43 @@ async def test_switch_api_failure(hass: HomeAssistant) -> None: assert exc_info.value.translation_placeholders == { "error": "test error from API." } + + +async def test_switch_dynamic(hass: HomeAssistant) -> None: + """Test switch dynamically added.""" + await setup_uptimerobot_integration(hass) + + assert (entity := hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)) + assert entity.state == STATE_ON + + entity_id_2 = "switch.test_monitor_2" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response( + data=[ + { + "id": 1234, + "friendly_name": "Test monitor", + "status": 2, + "type": 1, + "url": "http://example.com", + }, + { + "id": 5678, + "friendly_name": "Test monitor 2", + "status": 2, + "type": 1, + "url": "http://example2.com", + }, + ] + ), + ): + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + + assert (entity := hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)) + assert entity.state == STATE_ON + + assert (entity := hass.states.get(entity_id_2)) + assert entity.state == STATE_ON From 1dccbee45c79061ce561d25d240008870707a4c7 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 24 Sep 2025 11:28:10 +0100 Subject: [PATCH 102/189] Remove hardware flow thread confirm step after install (#152868) --- .../firmware_config_flow.py | 14 --- .../test_config_flow.py | 13 +-- .../test_config_flow.py | 101 +++++++----------- .../test_config_flow.py | 13 +-- .../homeassistant_yellow/test_config_flow.py | 14 +-- 5 files changed, 50 insertions(+), 105 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 6ea568890f98..61678b11395c 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -641,20 +641,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): """Pre-confirm OTBR setup.""" # This step is necessary to prevent `user_input` from being passed through - return await self.async_step_confirm_otbr() - - async def async_step_confirm_otbr( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Confirm OTBR setup.""" - assert self._device is not None - - if user_input is None: - return self.async_show_form( - step_id="confirm_otbr", - description_placeholders=self._get_translation_placeholders(), - ) - # OTBR discovery is done automatically via hassio return self._async_flow_finished() diff --git a/tests/components/homeassistant_connect_zbt2/test_config_flow.py b/tests/components/homeassistant_connect_zbt2/test_config_flow.py index b1372fe44832..ff26c246a40f 100644 --- a/tests/components/homeassistant_connect_zbt2/test_config_flow.py +++ b/tests/components/homeassistant_connect_zbt2/test_config_flow.py @@ -187,19 +187,12 @@ async def test_config_flow_thread( # Make sure the flow continues when the progress task is done. await hass.async_block_till_done() - confirm_result = await hass.config_entries.flow.async_configure( + create_result = await hass.config_entries.flow.async_configure( result["flow_id"] ) - assert start_addon.call_count == 1 - assert start_addon.call_args == call("core_openthread_border_router") - assert confirm_result["type"] is FlowResultType.FORM - assert confirm_result["step_id"] == "confirm_otbr" - - create_result = await hass.config_entries.flow.async_configure( - confirm_result["flow_id"], user_input={} - ) - + assert start_addon.call_count == 1 + assert start_addon.call_args == call("core_openthread_border_router") assert create_result["type"] is FlowResultType.CREATE_ENTRY config_entry = create_result["result"] assert config_entry.data == { diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index c7c2535e3727..8cc5fdbc89c5 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -705,7 +705,7 @@ async def test_config_flow_thread( await hass.async_block_till_done(wait_background_tasks=True) # Progress the flow, it is now installing firmware - confirm_otbr_result = await consume_progress_flow( + create_result = await consume_progress_flow( hass, flow_id=pick_result["flow_id"], valid_step_ids=( @@ -717,9 +717,6 @@ async def test_config_flow_thread( ) # Installation will conclude with the config entry being created - create_result = await hass.config_entries.flow.async_configure( - confirm_otbr_result["flow_id"], user_input={} - ) assert create_result["type"] is FlowResultType.CREATE_ENTRY config_entry = create_result["result"] @@ -766,7 +763,7 @@ async def test_config_flow_thread_addon_already_installed( ) # Progress - confirm_otbr_result = await consume_progress_flow( + create_result = await consume_progress_flow( hass, flow_id=pick_result["flow_id"], valid_step_ids=( @@ -776,35 +773,26 @@ async def test_config_flow_thread_addon_already_installed( ), ) - # We're now waiting to confirm OTBR - assert confirm_otbr_result["type"] is FlowResultType.FORM - assert confirm_otbr_result["step_id"] == "confirm_otbr" - - # The addon has been installed - assert set_addon_options.call_args == call( - "core_openthread_border_router", - AddonsOptions( - config={ - "device": "/dev/SomeDevice123", - "baudrate": 460800, - "flow_control": True, - "autoflash_firmware": False, - }, - ), - ) - assert start_addon.call_count == 1 - assert start_addon.call_args == call("core_openthread_border_router") - - # Finally, create the config entry - create_result = await hass.config_entries.flow.async_configure( - confirm_otbr_result["flow_id"], user_input={} - ) - assert create_result["type"] is FlowResultType.CREATE_ENTRY - assert create_result["result"].data == { - "firmware": "spinel", - "device": TEST_DEVICE, - "hardware": TEST_HARDWARE_NAME, - } + # The add-on has been installed + assert set_addon_options.call_args == call( + "core_openthread_border_router", + AddonsOptions( + config={ + "device": "/dev/SomeDevice123", + "baudrate": 460800, + "flow_control": True, + "autoflash_firmware": False, + }, + ), + ) + assert start_addon.call_count == 1 + assert start_addon.call_args == call("core_openthread_border_router") + assert create_result["type"] is FlowResultType.CREATE_ENTRY + assert create_result["result"].data == { + "firmware": "spinel", + "device": TEST_DEVICE, + "hardware": TEST_HARDWARE_NAME, + } @pytest.mark.usefixtures("addon_not_installed") @@ -870,33 +858,26 @@ async def test_options_flow_zigbee_to_thread( result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_otbr" - assert install_addon.call_count == 1 - assert install_addon.call_args == call("core_openthread_border_router") - assert set_addon_options.call_count == 1 - assert set_addon_options.call_args == call( - "core_openthread_border_router", - AddonsOptions( - config={ - "device": "/dev/SomeDevice123", - "baudrate": 460800, - "flow_control": True, - "autoflash_firmware": False, - }, - ), - ) - assert start_addon.call_count == 1 - assert start_addon.call_args == call("core_openthread_border_router") + assert install_addon.call_count == 1 + assert install_addon.call_args == call("core_openthread_border_router") + assert set_addon_options.call_count == 1 + assert set_addon_options.call_args == call( + "core_openthread_border_router", + AddonsOptions( + config={ + "device": "/dev/SomeDevice123", + "baudrate": 460800, + "flow_control": True, + "autoflash_firmware": False, + }, + ), + ) + assert start_addon.call_count == 1 + assert start_addon.call_args == call("core_openthread_border_router") + assert result["type"] is FlowResultType.CREATE_ENTRY - # We are now done - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - - # The firmware type has been updated - assert config_entry.data["firmware"] == "spinel" + # The firmware type has been updated + assert config_entry.data["firmware"] == "spinel" @pytest.mark.usefixtures("addon_store_info") diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 6fd4b05a13ee..d977a2ba8a14 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -220,19 +220,12 @@ async def test_config_flow_thread( # Make sure the flow continues when the progress task is done. await hass.async_block_till_done() - confirm_result = await hass.config_entries.flow.async_configure( + create_result = await hass.config_entries.flow.async_configure( result["flow_id"] ) - assert start_addon.call_count == 1 - assert start_addon.call_args == call("core_openthread_border_router") - assert confirm_result["type"] is FlowResultType.FORM - assert confirm_result["step_id"] == ("confirm_otbr") - - create_result = await hass.config_entries.flow.async_configure( - confirm_result["flow_id"], user_input={} - ) - + assert start_addon.call_count == 1 + assert start_addon.call_args == call("core_openthread_border_router") assert create_result["type"] is FlowResultType.CREATE_ENTRY config_entry = create_result["result"] assert config_entry.data == { diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 160e470ad1e6..df4bee29eab0 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -487,21 +487,13 @@ async def test_firmware_options_flow_thread( # Make sure the flow continues when the progress task is done. await hass.async_block_till_done() - confirm_result = await hass.config_entries.options.async_configure( + create_result = await hass.config_entries.options.async_configure( result["flow_id"] ) - assert start_addon.call_count == 1 - assert start_addon.call_args == call("core_openthread_border_router") - assert confirm_result["type"] is FlowResultType.FORM - assert confirm_result["step_id"] == ("confirm_otbr") - - create_result = await hass.config_entries.options.async_configure( - confirm_result["flow_id"], user_input={} - ) - + assert start_addon.call_count == 1 + assert start_addon.call_args == call("core_openthread_border_router") assert create_result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.data == { "firmware": fw_type.value, "firmware_version": fw_version, From afefa1661521633d64dbc32d129adbf0d7a2c205 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 24 Sep 2025 12:29:33 +0200 Subject: [PATCH 103/189] Remove analytics platform in automation (#152875) --- .../components/automation/analytics.py | 24 ----------- tests/components/automation/test_analytics.py | 41 ------------------- 2 files changed, 65 deletions(-) delete mode 100644 homeassistant/components/automation/analytics.py delete mode 100644 tests/components/automation/test_analytics.py diff --git a/homeassistant/components/automation/analytics.py b/homeassistant/components/automation/analytics.py deleted file mode 100644 index 06c9a553d8ae..000000000000 --- a/homeassistant/components/automation/analytics.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Analytics platform.""" - -from homeassistant.components.analytics import ( - AnalyticsInput, - AnalyticsModifications, - EntityAnalyticsModifications, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - - -async def async_modify_analytics( - hass: HomeAssistant, analytics_input: AnalyticsInput -) -> AnalyticsModifications: - """Modify the analytics.""" - ent_reg = er.async_get(hass) - - entities: dict[str, EntityAnalyticsModifications] = {} - for entity_id in analytics_input.entity_ids: - entity_entry = ent_reg.entities[entity_id] - if entity_entry.capabilities is not None: - entities[entity_id] = EntityAnalyticsModifications(capabilities=None) - - return AnalyticsModifications(entities=entities) diff --git a/tests/components/automation/test_analytics.py b/tests/components/automation/test_analytics.py deleted file mode 100644 index 803103d0245c..000000000000 --- a/tests/components/automation/test_analytics.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Tests for analytics platform.""" - -import pytest - -from homeassistant.components.analytics import async_devices_payload -from homeassistant.components.automation import DOMAIN -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component - - -@pytest.mark.asyncio -async def test_analytics( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: - """Test the analytics platform.""" - await async_setup_component(hass, "analytics", {}) - - entity_registry.async_get_or_create( - domain="automation", - platform="automation", - unique_id="automation1", - suggested_object_id="automation1", - capabilities={"id": "automation1"}, - ) - - result = await async_devices_payload(hass) - assert result["integrations"][DOMAIN]["entities"] == [ - { - "assumed_state": None, - "capabilities": None, - "domain": "automation", - "entity_category": None, - "has_entity_name": False, - "modified_by_integration": [ - "capabilities", - ], - "original_device_class": None, - "unit_of_measurement": None, - }, - ] From 4ea4eec2d81c9b52d461bf64097eb355c97c05a5 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 24 Sep 2025 12:32:14 +0200 Subject: [PATCH 104/189] Remove analytics platform in template (#152876) --- .../components/template/analytics.py | 43 ------- tests/components/template/test_analytics.py | 105 ------------------ 2 files changed, 148 deletions(-) delete mode 100644 homeassistant/components/template/analytics.py delete mode 100644 tests/components/template/test_analytics.py diff --git a/homeassistant/components/template/analytics.py b/homeassistant/components/template/analytics.py deleted file mode 100644 index e4db2c5c70a6..000000000000 --- a/homeassistant/components/template/analytics.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Analytics platform.""" - -from homeassistant.components.analytics import ( - AnalyticsInput, - AnalyticsModifications, - EntityAnalyticsModifications, -) -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers import entity_registry as er - -FILTERED_PLATFORM_CAPABILITY: dict[str, str] = { - Platform.FAN: "preset_modes", - Platform.SELECT: "options", -} - - -async def async_modify_analytics( - hass: HomeAssistant, analytics_input: AnalyticsInput -) -> AnalyticsModifications: - """Modify the analytics.""" - ent_reg = er.async_get(hass) - - entities: dict[str, EntityAnalyticsModifications] = {} - for entity_id in analytics_input.entity_ids: - platform = split_entity_id(entity_id)[0] - if platform not in FILTERED_PLATFORM_CAPABILITY: - continue - - entity_entry = ent_reg.entities[entity_id] - if entity_entry.capabilities is not None: - filtered_capability = FILTERED_PLATFORM_CAPABILITY[platform] - if filtered_capability not in entity_entry.capabilities: - continue - - capabilities = dict(entity_entry.capabilities) - capabilities[filtered_capability] = len(capabilities[filtered_capability]) - - entities[entity_id] = EntityAnalyticsModifications( - capabilities=capabilities - ) - - return AnalyticsModifications(entities=entities) diff --git a/tests/components/template/test_analytics.py b/tests/components/template/test_analytics.py deleted file mode 100644 index 33a0373bd170..000000000000 --- a/tests/components/template/test_analytics.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Tests for analytics platform.""" - -import pytest - -from homeassistant.components.analytics import async_devices_payload -from homeassistant.components.template import DOMAIN -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component - - -@pytest.mark.asyncio -async def test_analytics( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: - """Test the analytics platform.""" - await async_setup_component(hass, "analytics", {}) - - entity_registry.async_get_or_create( - domain=Platform.FAN, - platform="template", - unique_id="fan1", - suggested_object_id="my_fan", - capabilities={"options": ["a", "b", "c"], "preset_modes": ["auto", "eco"]}, - ) - entity_registry.async_get_or_create( - domain=Platform.SELECT, - platform="template", - unique_id="select1", - suggested_object_id="my_select", - capabilities={"not_filtered": "xyz", "options": ["a", "b", "c"]}, - ) - entity_registry.async_get_or_create( - domain=Platform.SELECT, - platform="template", - unique_id="select2", - suggested_object_id="my_select", - capabilities={"not_filtered": "xyz"}, - ) - entity_registry.async_get_or_create( - domain=Platform.LIGHT, - platform="template", - unique_id="light1", - suggested_object_id="my_light", - capabilities={"not_filtered": "abc"}, - ) - - result = await async_devices_payload(hass) - assert result["integrations"][DOMAIN]["entities"] == [ - { - "assumed_state": None, - "capabilities": { - "options": ["a", "b", "c"], - "preset_modes": 2, - }, - "domain": "fan", - "entity_category": None, - "has_entity_name": False, - "modified_by_integration": [ - "capabilities", - ], - "original_device_class": None, - "unit_of_measurement": None, - }, - { - "assumed_state": None, - "capabilities": { - "not_filtered": "xyz", - "options": 3, - }, - "domain": "select", - "entity_category": None, - "has_entity_name": False, - "modified_by_integration": [ - "capabilities", - ], - "original_device_class": None, - "unit_of_measurement": None, - }, - { - "assumed_state": None, - "capabilities": { - "not_filtered": "xyz", - }, - "domain": "select", - "entity_category": None, - "has_entity_name": False, - "modified_by_integration": None, - "original_device_class": None, - "unit_of_measurement": None, - }, - { - "assumed_state": None, - "capabilities": { - "not_filtered": "abc", - }, - "domain": "light", - "entity_category": None, - "has_entity_name": False, - "modified_by_integration": None, - "original_device_class": None, - "unit_of_measurement": None, - }, - ] From 0f904d418bb2422340f87a0395f5f3a19ce3e2ac Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 24 Sep 2025 12:32:30 +0200 Subject: [PATCH 105/189] Filter out integration types in extended analytics (#152874) --- .../components/analytics/analytics.py | 3 +- tests/components/analytics/test_analytics.py | 40 ++++++++++++++++--- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index b527c8ab9372..22e641c414a4 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -546,12 +546,13 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: if isinstance(integration, Integration) } - # Filter out custom integrations + # Filter out custom integrations and integrations that are not device or hub type integration_inputs = { domain: integration_info for domain, integration_info in integration_inputs.items() if (integration := integrations.get(domain)) is not None and integration.is_built_in + and integration.integration_type in ("device", "hub") } # Call integrations that implement the analytics platform diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 9a63f4b29cba..4a98d9770e4f 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -1055,6 +1055,16 @@ async def test_devices_payload_no_entities( model_id="test-model-id7", ) + # Device from an integration with a service type + mock_service_config_entry = MockConfigEntry(domain="uptime") + mock_service_config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=mock_service_config_entry.entry_id, + identifiers={("device", "8")}, + manufacturer="test-manufacturer8", + model_id="test-model-id8", + ) + client = await hass_client() response = await client.get("/api/analytics/devices") assert response.status == HTTPStatus.OK @@ -1173,21 +1183,29 @@ async def test_devices_payload_with_entities( original_device_class=NumberDeviceClass.TEMPERATURE, ) hass.states.async_set("number.hue_1", "2") - # Helper entity with assumed state + # Entity with assumed state entity_registry.async_get_or_create( domain="light", - platform="template", + platform="hue", + unique_id="2", + device_id=device_entry.id, + has_entity_name=True, + ) + hass.states.async_set("light.hue_2", "on", {ATTR_ASSUMED_STATE: True}) + # Entity from a different integration + entity_registry.async_get_or_create( + domain="light", + platform="roomba", unique_id="1", device_id=device_entry.id, has_entity_name=True, ) - hass.states.async_set("light.template_1", "on", {ATTR_ASSUMED_STATE: True}) # Second device entity_registry.async_get_or_create( domain="light", platform="hue", - unique_id="2", + unique_id="3", device_id=device_entry_2.id, ) @@ -1235,6 +1253,16 @@ async def test_devices_payload_with_entities( "original_device_class": "temperature", "unit_of_measurement": None, }, + { + "assumed_state": True, + "capabilities": None, + "domain": "light", + "entity_category": None, + "has_entity_name": True, + "modified_by_integration": None, + "original_device_class": None, + "unit_of_measurement": None, + }, ], "entry_type": None, "has_configuration_url": False, @@ -1281,11 +1309,11 @@ async def test_devices_payload_with_entities( }, ], }, - "template": { + "roomba": { "devices": [], "entities": [ { - "assumed_state": True, + "assumed_state": None, "capabilities": None, "domain": "light", "entity_category": None, From 475b84cc5f9ecef5f2c2be5930ba8fbbdd84ae2e Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 24 Sep 2025 12:43:22 +0200 Subject: [PATCH 106/189] Remove codeowner. (#152869) --- CODEOWNERS | 2 -- homeassistant/components/modbus/manifest.json | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 46413e834fc1..59b72f3550b3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -974,8 +974,6 @@ build.json @home-assistant/supervisor /tests/components/moat/ @bdraco /homeassistant/components/mobile_app/ @home-assistant/core /tests/components/mobile_app/ @home-assistant/core -/homeassistant/components/modbus/ @janiversen -/tests/components/modbus/ @janiversen /homeassistant/components/modem_callerid/ @tkdrob /tests/components/modem_callerid/ @tkdrob /homeassistant/components/modern_forms/ @wonderslug diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 429633224239..190766bf7965 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -1,7 +1,7 @@ { "domain": "modbus", "name": "Modbus", - "codeowners": ["@janiversen"], + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/modbus", "iot_class": "local_polling", "loggers": ["pymodbus"], From 8782aa4f6089187533876de8475710a8fa111eb9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 24 Sep 2025 12:49:44 +0200 Subject: [PATCH 107/189] Hide asserts behind TYPE_CHECKING in Synology DSM (#152880) --- .../components/synology_dsm/__init__.py | 7 ++-- .../components/synology_dsm/binary_sensor.py | 7 ++-- .../components/synology_dsm/button.py | 10 +++--- .../components/synology_dsm/camera.py | 16 ++++++--- .../components/synology_dsm/coordinator.py | 11 +++--- .../components/synology_dsm/entity.py | 36 +++++++++++-------- .../components/synology_dsm/media_source.py | 22 ++++++++---- .../components/synology_dsm/sensor.py | 8 +++-- .../components/synology_dsm/services.py | 5 +-- .../components/synology_dsm/switch.py | 22 +++++++----- .../components/synology_dsm/update.py | 13 ++++--- 11 files changed, 100 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 7146d42136ee..d52547980720 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from itertools import chain import logging +from typing import TYPE_CHECKING from synology_dsm.api.surveillance_station import SynoSurveillanceStation from synology_dsm.api.surveillance_station.camera import SynoCamera @@ -177,10 +178,12 @@ async def async_remove_config_entry_device( """Remove synology_dsm config entry from a device.""" data = entry.runtime_data api = data.api - assert api.information is not None + if TYPE_CHECKING: + assert api.information is not None serial = api.information.serial storage = api.storage - assert storage is not None + if TYPE_CHECKING: + assert storage is not None all_cameras: list[SynoCamera] = [] if api.surveillance_station is not None: # get_all_cameras does not do I/O diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index 1ae5fa907605..3af87f9756d2 100644 --- a/homeassistant/components/synology_dsm/binary_sensor.py +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import TYPE_CHECKING from synology_dsm.api.core.security import SynoCoreSecurity from synology_dsm.api.storage.storage import SynoStorage @@ -68,7 +69,8 @@ async def async_setup_entry( data = entry.runtime_data api = data.api coordinator = data.coordinator_central - assert api.storage is not None + if TYPE_CHECKING: + assert api.storage is not None entities: list[SynoDSMSecurityBinarySensor | SynoDSMStorageBinarySensor] = [ SynoDSMSecurityBinarySensor(api, coordinator, description) @@ -121,7 +123,8 @@ class SynoDSMSecurityBinarySensor(SynoDSMBinarySensor): @property def extra_state_attributes(self) -> dict[str, str]: """Return security checks details.""" - assert self._api.security is not None + if TYPE_CHECKING: + assert self._api.security is not None return self._api.security.status_by_check diff --git a/homeassistant/components/synology_dsm/button.py b/homeassistant/components/synology_dsm/button.py index 79297b1f1b4f..9c99f3a4c2a4 100644 --- a/homeassistant/components/synology_dsm/button.py +++ b/homeassistant/components/synology_dsm/button.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass import logging -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final from homeassistant.components.button import ( ButtonDeviceClass, @@ -72,8 +72,9 @@ class SynologyDSMButton(ButtonEntity): """Initialize the Synology DSM binary_sensor entity.""" self.entity_description = description self.syno_api = api - assert api.network is not None - assert api.information is not None + if TYPE_CHECKING: + assert api.network is not None + assert api.information is not None self._attr_name = f"{api.network.hostname} {description.name}" self._attr_unique_id = f"{api.information.serial}_{description.key}" self._attr_device_info = DeviceInfo( @@ -82,7 +83,8 @@ class SynologyDSMButton(ButtonEntity): async def async_press(self) -> None: """Triggers the Synology DSM button press service.""" - assert self.syno_api.network is not None + if TYPE_CHECKING: + assert self.syno_api.network is not None LOGGER.debug( "Trigger %s for %s", self.entity_description.key, diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index f393b8efb552..56183804e5f1 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -4,6 +4,7 @@ from __future__ import annotations from dataclasses import dataclass import logging +from typing import TYPE_CHECKING from synology_dsm.api.surveillance_station import SynoCamera, SynoSurveillanceStation from synology_dsm.exceptions import ( @@ -94,7 +95,8 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C def device_info(self) -> DeviceInfo: """Return the device information.""" information = self._api.information - assert information is not None + if TYPE_CHECKING: + assert information is not None return DeviceInfo( identifiers={(DOMAIN, f"{information.serial}_{self.camera_data.id}")}, name=self.camera_data.name, @@ -129,7 +131,8 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C _LOGGER.debug("Update stream URL for camera %s", self.camera_data.name) self.stream.update_source(url) - assert self.platform.config_entry + if TYPE_CHECKING: + assert self.platform.config_entry self.async_on_remove( async_dispatcher_connect( self.hass, @@ -153,7 +156,8 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C ) if not self.available: return None - assert self._api.surveillance_station is not None + if TYPE_CHECKING: + assert self._api.surveillance_station is not None try: return await self._api.surveillance_station.get_camera_image( self.entity_description.camera_id, self.snapshot_quality @@ -187,7 +191,8 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C "SynoDSMCamera.enable_motion_detection(%s)", self.camera_data.name, ) - assert self._api.surveillance_station is not None + if TYPE_CHECKING: + assert self._api.surveillance_station is not None await self._api.surveillance_station.enable_motion_detection( self.entity_description.camera_id ) @@ -198,7 +203,8 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C "SynoDSMCamera.disable_motion_detection(%s)", self.camera_data.name, ) - assert self._api.surveillance_station is not None + if TYPE_CHECKING: + assert self._api.surveillance_station is not None await self._api.surveillance_station.disable_motion_detection( self.entity_description.camera_id ) diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py index dd97dedf65e2..c2fa275c7de9 100644 --- a/homeassistant/components/synology_dsm/coordinator.py +++ b/homeassistant/components/synology_dsm/coordinator.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass from datetime import timedelta import logging -from typing import Any, Concatenate +from typing import TYPE_CHECKING, Any, Concatenate from synology_dsm.api.surveillance_station.camera import SynoCamera from synology_dsm.exceptions import ( @@ -110,14 +110,16 @@ class SynologyDSMSwitchUpdateCoordinator( async def async_setup(self) -> None: """Set up the coordinator initial data.""" info = await self.api.dsm.surveillance_station.get_info() - assert info is not None + if TYPE_CHECKING: + assert info is not None self.version = info["data"]["CMSMinVersion"] @async_re_login_on_expired async def _async_update_data(self) -> dict[str, dict[str, Any]]: """Fetch all data from api.""" surveillance_station = self.api.surveillance_station - assert surveillance_station is not None + if TYPE_CHECKING: + assert surveillance_station is not None return { "switches": { "home_mode": bool(await surveillance_station.get_home_mode_status()) @@ -161,7 +163,8 @@ class SynologyDSMCameraUpdateCoordinator( async def _async_update_data(self) -> dict[str, dict[int, SynoCamera]]: """Fetch all camera data from api.""" surveillance_station = self.api.surveillance_station - assert surveillance_station is not None + if TYPE_CHECKING: + assert surveillance_station is not None current_data: dict[int, SynoCamera] = { camera.id: camera for camera in surveillance_station.get_all_cameras() } diff --git a/homeassistant/components/synology_dsm/entity.py b/homeassistant/components/synology_dsm/entity.py index 85269b9c4801..3ffbcce54665 100644 --- a/homeassistant/components/synology_dsm/entity.py +++ b/homeassistant/components/synology_dsm/entity.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription @@ -47,8 +47,9 @@ class SynologyDSMBaseEntity[_CoordinatorT: SynologyDSMUpdateCoordinator[Any]]( self._api = api information = api.information network = api.network - assert information is not None - assert network is not None + if TYPE_CHECKING: + assert information is not None + assert network is not None self._attr_unique_id: str = ( f"{information.serial}_{description.api_key}:{description.key}" @@ -94,14 +95,17 @@ class SynologyDSMDeviceEntity( information = api.information network = api.network external_usb = api.external_usb - assert information is not None - assert storage is not None - assert network is not None + if TYPE_CHECKING: + assert information is not None + assert storage is not None + assert network is not None if "volume" in description.key: - assert self._device_id is not None + if TYPE_CHECKING: + assert self._device_id is not None volume = storage.get_volume(self._device_id) - assert volume is not None + if TYPE_CHECKING: + assert volume is not None # Volume does not have a name self._device_name = volume["id"].replace("_", " ").capitalize() self._device_manufacturer = "Synology" @@ -114,17 +118,20 @@ class SynologyDSMDeviceEntity( .replace("shr", "SHR") ) elif "disk" in description.key: - assert self._device_id is not None + if TYPE_CHECKING: + assert self._device_id is not None disk = storage.get_disk(self._device_id) - assert disk is not None + if TYPE_CHECKING: + assert disk is not None self._device_name = disk["name"] self._device_manufacturer = disk["vendor"] self._device_model = disk["model"].strip() self._device_firmware = disk["firm"] self._device_type = disk["diskType"] elif "device" in description.key: - assert self._device_id is not None - assert external_usb is not None + if TYPE_CHECKING: + assert self._device_id is not None + assert external_usb is not None for device in external_usb.get_devices.values(): if device.device_name == self._device_id: self._device_name = device.device_name @@ -133,8 +140,9 @@ class SynologyDSMDeviceEntity( self._device_type = device.device_type break elif "partition" in description.key: - assert self._device_id is not None - assert external_usb is not None + if TYPE_CHECKING: + assert self._device_id is not None + assert external_usb is not None for device in external_usb.get_devices.values(): for partition in device.device_partitions.values(): if partition.partition_title == self._device_id: diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py index 9f9f308df5da..94edef603ce6 100644 --- a/homeassistant/components/synology_dsm/media_source.py +++ b/homeassistant/components/synology_dsm/media_source.py @@ -4,6 +4,7 @@ from __future__ import annotations from logging import getLogger import mimetypes +from typing import TYPE_CHECKING from aiohttp import web from synology_dsm.api.photos import SynoPhotosAlbum, SynoPhotosItem @@ -121,9 +122,11 @@ class SynologyPhotosMediaSource(MediaSource): DOMAIN, identifier.unique_id ) ) - assert entry + if TYPE_CHECKING: + assert entry diskstation = entry.runtime_data - assert diskstation.api.photos is not None + if TYPE_CHECKING: + assert diskstation.api.photos is not None if identifier.album_id is None: # Get Albums @@ -131,7 +134,8 @@ class SynologyPhotosMediaSource(MediaSource): albums = await diskstation.api.photos.get_albums() except SynologyDSMException: return [] - assert albums is not None + if TYPE_CHECKING: + assert albums is not None ret = [ BrowseMediaSource( @@ -190,7 +194,8 @@ class SynologyPhotosMediaSource(MediaSource): ) except SynologyDSMException: return [] - assert album_items is not None + if TYPE_CHECKING: + assert album_items is not None ret = [] for album_item in album_items: @@ -249,7 +254,8 @@ class SynologyPhotosMediaSource(MediaSource): self, item: SynoPhotosItem, diskstation: SynologyDSMData ) -> str | None: """Get thumbnail.""" - assert diskstation.api.photos is not None + if TYPE_CHECKING: + assert diskstation.api.photos is not None try: thumbnail = await diskstation.api.photos.get_item_thumbnail_url(item) @@ -290,9 +296,11 @@ class SynologyDsmMediaView(http.HomeAssistantView): DOMAIN, source_dir_id ) ) - assert entry + if TYPE_CHECKING: + assert entry diskstation = entry.runtime_data - assert diskstation.api.photos is not None + if TYPE_CHECKING: + assert diskstation.api.photos is not None item = SynoPhotosItem(image_id, "", "", "", cache_key, "xl", shared, passphrase) try: if passphrase: diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 85a847cbe805..dd46fa33c3a2 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta -from typing import cast +from typing import TYPE_CHECKING, cast from synology_dsm.api.core.external_usb import ( SynoCoreExternalUSB, @@ -345,7 +345,8 @@ async def async_setup_entry( api = data.api coordinator = data.coordinator_central storage = api.storage - assert storage is not None + if TYPE_CHECKING: + assert storage is not None known_usb_devices: set[str] = set() def _check_usb_devices() -> None: @@ -504,7 +505,8 @@ class SynoDSMExternalUSBSensor(SynologyDSMDeviceEntity, SynoDSMSensor): def native_value(self) -> StateType: """Return the state.""" external_usb = self._api.external_usb - assert external_usb is not None + if TYPE_CHECKING: + assert external_usb is not None if "device" in self.entity_description.key: for device in external_usb.get_devices.values(): if device.device_name == self._device_id: diff --git a/homeassistant/components/synology_dsm/services.py b/homeassistant/components/synology_dsm/services.py index 9522361d500b..ad0615eaa566 100644 --- a/homeassistant/components/synology_dsm/services.py +++ b/homeassistant/components/synology_dsm/services.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import cast +from typing import TYPE_CHECKING, cast from synology_dsm.exceptions import SynologyDSMException @@ -27,7 +27,8 @@ async def _service_handler(call: ServiceCall) -> None: entry: SynologyDSMConfigEntry | None = ( call.hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial) ) - assert entry + if TYPE_CHECKING: + assert entry dsm_device = entry.runtime_data elif len(dsm_devices) == 1: dsm_device = next(iter(dsm_devices.values())) diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index 91863ff3a260..8be6dedd8ca7 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass import logging -from typing import Any +from typing import TYPE_CHECKING, Any from synology_dsm.api.surveillance_station import SynoSurveillanceStation @@ -45,7 +45,8 @@ async def async_setup_entry( """Set up the Synology NAS switch.""" data = entry.runtime_data if coordinator := data.coordinator_switches: - assert coordinator.version is not None + if TYPE_CHECKING: + assert coordinator.version is not None async_add_entities( SynoDSMSurveillanceHomeModeToggle( data.api, coordinator.version, coordinator, description @@ -79,8 +80,9 @@ class SynoDSMSurveillanceHomeModeToggle( async def async_turn_on(self, **kwargs: Any) -> None: """Turn on Home mode.""" - assert self._api.surveillance_station is not None - assert self._api.information + if TYPE_CHECKING: + assert self._api.surveillance_station is not None + assert self._api.information _LOGGER.debug( "SynoDSMSurveillanceHomeModeToggle.turn_on(%s)", self._api.information.serial, @@ -90,8 +92,9 @@ class SynoDSMSurveillanceHomeModeToggle( async def async_turn_off(self, **kwargs: Any) -> None: """Turn off Home mode.""" - assert self._api.surveillance_station is not None - assert self._api.information + if TYPE_CHECKING: + assert self._api.surveillance_station is not None + assert self._api.information _LOGGER.debug( "SynoDSMSurveillanceHomeModeToggle.turn_off(%s)", self._api.information.serial, @@ -107,9 +110,10 @@ class SynoDSMSurveillanceHomeModeToggle( @property def device_info(self) -> DeviceInfo: """Return the device information.""" - assert self._api.surveillance_station is not None - assert self._api.information is not None - assert self._api.network is not None + if TYPE_CHECKING: + assert self._api.surveillance_station is not None + assert self._api.information is not None + assert self._api.network is not None return DeviceInfo( identifiers={ ( diff --git a/homeassistant/components/synology_dsm/update.py b/homeassistant/components/synology_dsm/update.py index 3048a38cb9c5..6b421f639e7c 100644 --- a/homeassistant/components/synology_dsm/update.py +++ b/homeassistant/components/synology_dsm/update.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Final +from typing import TYPE_CHECKING, Final from synology_dsm.api.core.upgrade import SynoCoreUpgrade from yarl import URL @@ -63,13 +63,15 @@ class SynoDSMUpdateEntity( @property def installed_version(self) -> str | None: """Version installed and in use.""" - assert self._api.information is not None + if TYPE_CHECKING: + assert self._api.information is not None return self._api.information.version_string @property def latest_version(self) -> str | None: """Latest version available for install.""" - assert self._api.upgrade is not None + if TYPE_CHECKING: + assert self._api.upgrade is not None if not self._api.upgrade.update_available: return self.installed_version return self._api.upgrade.available_version @@ -77,8 +79,9 @@ class SynoDSMUpdateEntity( @property def release_url(self) -> str | None: """URL to the full release notes of the latest version available.""" - assert self._api.information is not None - assert self._api.upgrade is not None + if TYPE_CHECKING: + assert self._api.information is not None + assert self._api.upgrade is not None if (details := self._api.upgrade.available_version_details) is None: return None From 332a3fad3c6948f7157d63c1f869f12b2ec65b5a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 24 Sep 2025 13:09:32 +0200 Subject: [PATCH 108/189] Fix mypy errors (#152879) --- homeassistant/components/acaia/coordinator.py | 4 +--- homeassistant/components/homekit_controller/utils.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/acaia/coordinator.py b/homeassistant/components/acaia/coordinator.py index 629e61c395ca..b42cbccaee50 100644 --- a/homeassistant/components/acaia/coordinator.py +++ b/homeassistant/components/acaia/coordinator.py @@ -4,11 +4,9 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import cast from aioacaia.acaiascale import AcaiaScale from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError -from bleak import BleakScanner from homeassistant.components.bluetooth import async_get_scanner from homeassistant.config_entries import ConfigEntry @@ -45,7 +43,7 @@ class AcaiaCoordinator(DataUpdateCoordinator[None]): name=entry.title, is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE], notify_callback=self.async_update_listeners, - scanner=cast(BleakScanner, async_get_scanner(hass)), + scanner=async_get_scanner(hass), ) @property diff --git a/homeassistant/components/homekit_controller/utils.py b/homeassistant/components/homekit_controller/utils.py index ac436ce27a49..9d04576ec28a 100644 --- a/homeassistant/components/homekit_controller/utils.py +++ b/homeassistant/components/homekit_controller/utils.py @@ -63,7 +63,7 @@ async def async_get_controller(hass: HomeAssistant) -> Controller: controller = Controller( async_zeroconf_instance=async_zeroconf_instance, - bleak_scanner_instance=bleak_scanner_instance, # type: ignore[arg-type] + bleak_scanner_instance=bleak_scanner_instance, char_cache=char_cache, ) From 9babc855178fc0bad7a064cd4108b24416efe3d3 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 24 Sep 2025 13:21:40 +0200 Subject: [PATCH 109/189] Add analytics to core files (#152877) --- .core_files.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.core_files.yaml b/.core_files.yaml index 2624c4432be5..5c6537aa2366 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -58,6 +58,7 @@ base_platforms: &base_platforms # Extra components that trigger the full suite components: &components - homeassistant/components/alexa/** + - homeassistant/components/analytics/** - homeassistant/components/application_credentials/** - homeassistant/components/assist_pipeline/** - homeassistant/components/auth/** From e14f5ba44de5653f3c5dd19d9c56cdbb2e360780 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 24 Sep 2025 13:22:32 +0200 Subject: [PATCH 110/189] Fix misleading + unclear comment in homeassistant.const (#152878) --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index fdea434b8cb7..3b9702b972ee 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -37,7 +37,7 @@ REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "" # Format for platform files PLATFORM_FORMAT: Final = "{platform}.{domain}" -# Type alias to avoid 1000 MyPy errors +# Explicit reexport to allow other modules to import Platform directly from const Platform = EntityPlatforms BASE_PLATFORMS: Final = {platform.value for platform in Platform} From 311d4c4262fd13a2a26ba4e1717f308cfaa46bb8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 24 Sep 2025 13:31:44 +0200 Subject: [PATCH 111/189] Use DeviceCategory in Tuya binary sensor (#152882) --- .../components/tuya/binary_sensor.py | 113 +++++------------ homeassistant/components/tuya/const.py | 119 ++++++++++++++---- 2 files changed, 127 insertions(+), 105 deletions(-) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 912de9464830..9a4be708880d 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.json import json_loads from . import TuyaConfigEntry -from .const import TUYA_DISCOVERY_NEW, DPCode, DPType +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity @@ -48,11 +48,8 @@ TAMPER_BINARY_SENSOR = TuyaBinarySensorEntityDescription( # All descriptions can be found here. Mostly the Boolean data types in the # default status set of each category (that don't have a set instruction) # end up being a binary sensor. -# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( +BINARY_SENSORS: dict[DeviceCategory, tuple[TuyaBinarySensorEntityDescription, ...]] = { + DeviceCategory.CO2BJ: ( TuyaBinarySensorEntityDescription( key=DPCode.CO2_STATE, device_class=BinarySensorDeviceClass.SAFETY, @@ -60,9 +57,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), - # CO Detector - # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v - "cobj": ( + DeviceCategory.COBJ: ( TuyaBinarySensorEntityDescription( key=DPCode.CO_STATE, device_class=BinarySensorDeviceClass.SAFETY, @@ -75,9 +70,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), - # Dehumidifier - # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha - "cs": ( + DeviceCategory.CS: ( TuyaBinarySensorEntityDescription( key="tankfull", dpcode=DPCode.FAULT, @@ -103,18 +96,14 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { translation_key="wet", ), ), - # Smart Pet Feeder - # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld - "cwwsq": ( + DeviceCategory.CWWSQ: ( TuyaBinarySensorEntityDescription( key=DPCode.FEED_STATE, translation_key="feeding", on_value="feeding", ), ), - # Multi-functional Sensor - # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 - "dgnbj": ( + DeviceCategory.DGNBJ: ( TuyaBinarySensorEntityDescription( key=DPCode.GAS_SENSOR_STATE, device_class=BinarySensorDeviceClass.GAS, @@ -177,18 +166,14 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), - # Human Presence Sensor - # https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs - "hps": ( + DeviceCategory.HPS: ( TuyaBinarySensorEntityDescription( key=DPCode.PRESENCE_STATE, device_class=BinarySensorDeviceClass.OCCUPANCY, on_value={"presence", "small_move", "large_move", "peaceful"}, ), ), - # Formaldehyde Detector - # Note: Not documented - "jqbj": ( + DeviceCategory.JQBJ: ( TuyaBinarySensorEntityDescription( key=DPCode.CH2O_STATE, device_class=BinarySensorDeviceClass.SAFETY, @@ -196,9 +181,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), - # Methane Detector - # https://developer.tuya.com/en/docs/iot/categoryjwbj?id=Kaiuz40u98lkm - "jwbj": ( + DeviceCategory.JWBJ: ( TuyaBinarySensorEntityDescription( key=DPCode.CH4_SENSOR_STATE, device_class=BinarySensorDeviceClass.GAS, @@ -206,9 +189,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), - # Luminance Sensor - # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 - "ldcg": ( + DeviceCategory.LDCG: ( TuyaBinarySensorEntityDescription( key=DPCode.TEMPER_ALARM, device_class=BinarySensorDeviceClass.TAMPER, @@ -216,18 +197,14 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), - # Door and Window Controller - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r5zjsy9 - "mc": ( + DeviceCategory.MC: ( TuyaBinarySensorEntityDescription( key=DPCode.STATUS, device_class=BinarySensorDeviceClass.DOOR, on_value={"open", "opened"}, ), ), - # Door Window Sensor - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48hm02l8m - "mcs": ( + DeviceCategory.MCS: ( TuyaBinarySensorEntityDescription( key=DPCode.DOORCONTACT_STATE, device_class=BinarySensorDeviceClass.DOOR, @@ -238,18 +215,14 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), - # Access Control - # https://developer.tuya.com/en/docs/iot/s?id=Kb0o2xhlkxbet - "mk": ( + DeviceCategory.MK: ( TuyaBinarySensorEntityDescription( key=DPCode.CLOSED_OPENED_KIT, device_class=BinarySensorDeviceClass.LOCK, on_value={"AQAB"}, ), ), - # PIR Detector - # https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80 - "pir": ( + DeviceCategory.PIR: ( TuyaBinarySensorEntityDescription( key=DPCode.PIR, device_class=BinarySensorDeviceClass.MOTION, @@ -257,9 +230,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), - # PM2.5 Sensor - # https://developer.tuya.com/en/docs/iot/categorypm25?id=Kaiuz3qof3yfu - "pm2.5": ( + DeviceCategory.PM2_5: ( TuyaBinarySensorEntityDescription( key=DPCode.PM25_STATE, device_class=BinarySensorDeviceClass.SAFETY, @@ -267,12 +238,8 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), - # Temperature and Humidity Sensor with External Probe - # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 - "qxj": (TAMPER_BINARY_SENSOR,), - # Gas Detector - # https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw - "rqbj": ( + DeviceCategory.QXJ: (TAMPER_BINARY_SENSOR,), + DeviceCategory.RQBJ: ( TuyaBinarySensorEntityDescription( key=DPCode.GAS_SENSOR_STATUS, device_class=BinarySensorDeviceClass.GAS, @@ -285,18 +252,14 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), - # Siren Alarm - # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu - "sgbj": ( + DeviceCategory.SGBJ: ( TuyaBinarySensorEntityDescription( key=DPCode.CHARGE_STATE, device_class=BinarySensorDeviceClass.BATTERY_CHARGING, ), TAMPER_BINARY_SENSOR, ), - # Water Detector - # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli - "sj": ( + DeviceCategory.SJ: ( TuyaBinarySensorEntityDescription( key=DPCode.WATERSENSOR_STATE, device_class=BinarySensorDeviceClass.MOISTURE, @@ -304,18 +267,14 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), - # Emergency Button - # https://developer.tuya.com/en/docs/iot/categorysos?id=Kaiuz3oi6agjy - "sos": ( + DeviceCategory.SOS: ( TuyaBinarySensorEntityDescription( key=DPCode.SOS_STATE, device_class=BinarySensorDeviceClass.SAFETY, ), TAMPER_BINARY_SENSOR, ), - # Volatile Organic Compound Sensor - # Note: Undocumented in cloud API docs, based on test device - "voc": ( + DeviceCategory.VOC: ( TuyaBinarySensorEntityDescription( key=DPCode.VOC_STATE, device_class=BinarySensorDeviceClass.SAFETY, @@ -323,9 +282,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), - # Gateway control - # https://developer.tuya.com/en/docs/iot/wg?id=Kbcdadk79ejok - "wg2": ( + DeviceCategory.WG2: ( TuyaBinarySensorEntityDescription( key=DPCode.MASTER_STATE, device_class=BinarySensorDeviceClass.PROBLEM, @@ -333,39 +290,29 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { on_value="alarm", ), ), - # Thermostat - # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 - "wk": ( + DeviceCategory.WK: ( TuyaBinarySensorEntityDescription( key=DPCode.VALVE_STATE, translation_key="valve", on_value="open", ), ), - # Thermostatic Radiator Valve - # Not documented - "wkf": ( + DeviceCategory.WKF: ( TuyaBinarySensorEntityDescription( key=DPCode.WINDOW_STATE, device_class=BinarySensorDeviceClass.WINDOW, on_value="opened", ), ), - # Temperature and Humidity Sensor - # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 - "wsdcg": (TAMPER_BINARY_SENSOR,), - # Pressure Sensor - # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm - "ylcg": ( + DeviceCategory.WSDCG: (TAMPER_BINARY_SENSOR,), + DeviceCategory.YLCG: ( TuyaBinarySensorEntityDescription( key=DPCode.PRESSURE_STATE, on_value="alarm", ), TAMPER_BINARY_SENSOR, ), - # Smoke Detector - # https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952 - "ywbj": ( + DeviceCategory.YWBJ: ( TuyaBinarySensorEntityDescription( key=DPCode.SMOKE_SENSOR_STATUS, device_class=BinarySensorDeviceClass.SMOKE, @@ -378,9 +325,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), - # Vibration Sensor - # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno - "zd": ( + DeviceCategory.ZD: ( TuyaBinarySensorEntityDescription( key=f"{DPCode.SHOCK_STATE}_vibration", dpcode=DPCode.SHOCK_STATE, diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index c0412e36625e..e3765e2d2ca7 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -130,17 +130,29 @@ class DeviceCategory(StrEnum): CN = "cn" """Milk dispenser""" CO2BJ = "co2bj" - """CO2 detector""" + """CO2 detector + + https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + """ COBJ = "cobj" - """CO detector""" + """CO detector + + https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v + """ CS = "cs" - """Dehumidifier""" + """Dehumidifier + + https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha + """ CWTSWSQ = "cwtswsq" """Pet treat feeder""" CWWQFSQ = "cwwqfsq" """Pet ball thrower""" CWWSQ = "cwwsq" - """Pet feeder""" + """Pet feeder + + https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld + """ CWYSJ = "cwysj" """Pet fountain""" CZ = "cz" @@ -157,7 +169,10 @@ class DeviceCategory(StrEnum): DD = "dd" """Strip lights""" DGNBJ = "dgnbj" - """Multi-functional alarm""" + """Multi-functional alarm + + https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 + """ DJ = "dj" """Light""" DLQ = "dlq" @@ -181,7 +196,10 @@ class DeviceCategory(StrEnum): HOTELMS = "hotelms" """Hotel lock""" HPS = "hps" - """Human presence sensor""" + """Human presence sensor + + https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs + """ JS = "js" """Water purifier""" JSQ = "jsq" @@ -191,7 +209,10 @@ class DeviceCategory(StrEnum): JTMSPRO = "jtmspro" """Residential lock pro""" JWBJ = "jwbj" - """Methane detector""" + """Methane detector + + https://developer.tuya.com/en/docs/iot/categoryjwbj?id=Kaiuz40u98lkm + """ KFJ = "kfj" """Coffee maker""" KG = "kg" @@ -208,7 +229,10 @@ class DeviceCategory(StrEnum): KTKZQ = "ktkzq" """Air conditioner controller""" LDCG = "ldcg" - """Luminance sensor""" + """Luminance sensor + + https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 + """ LILIAO = "liliao" """Physiotherapy product""" LYJ = "lyj" @@ -221,15 +245,24 @@ class DeviceCategory(StrEnum): MB = "mb" """Bread maker""" MC = "mc" - """Door/window controller""" + """Door/window controller + + https://developer.tuya.com/en/docs/iot/s?id=K9gf48r5zjsy9 + """ MCS = "mcs" - """Contact sensor""" + """Contact sensor + + https://developer.tuya.com/en/docs/iot/s?id=K9gf48hm02l8m + """ MG = "mg" """Rice cabinet""" MJJ = "mjj" """Towel rack""" MK = "mk" - """Access control""" + """Access control + + https://developer.tuya.com/en/docs/iot/s?id=Kb0o2xhlkxbet + """ MS = "ms" """Residential lock""" MS_CATEGORY = "ms_category" @@ -247,16 +280,25 @@ class DeviceCategory(StrEnum): PHOTOLOCK = "photolock" """Audio and video lock""" PIR = "pir" - """Human motion sensor""" + """Human motion sensor + + https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80 + """ PM2_5 = "pm2.5" - """PM2.5 detector""" + """PM2.5 detector + + https://developer.tuya.com/en/docs/iot/categorypm25?id=Kaiuz3qof3yfu + """ QN = "qn" """Heater https://developer.tuya.com/en/docs/iot/f?id=K9gf46epy4j82 """ RQBJ = "rqbj" - """Gas alarm""" + """Gas alarm + + https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw + """ RS = "rs" """Water heater @@ -272,11 +314,20 @@ class DeviceCategory(StrEnum): SF = "sf" """Sofa""" SGBJ = "sgbj" - """Siren alarm""" + """Siren alarm + + https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu + """ SJ = "sj" - """Water leak detector""" + """Water leak detector + + https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli + """ SOS = "sos" - """Emergency button""" + """Emergency button + + https://developer.tuya.com/en/docs/iot/categorysos?id=Kaiuz3oi6agjy + """ SP = "sp" """Smart camera @@ -308,7 +359,10 @@ class DeviceCategory(StrEnum): https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 """ WSDCG = "wsdcg" - """Temperature and humidity sensor""" + """Temperature and humidity sensor + + https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 + """ XDD = "xdd" """Ceiling light""" XFJ = "xfj" @@ -324,11 +378,20 @@ class DeviceCategory(StrEnum): YKQ = "ykq" """Remote control""" YLCG = "ylcg" - """Pressure sensor""" + """Pressure sensor + + https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm + """ YWBJ = "ywbj" - """Smoke alarm""" + """Smoke alarm + + https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952 + """ ZD = "zd" - """Vibration sensor""" + """Vibration sensor + + https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno + """ ZNDB = "zndb" """Smart electricity meter""" ZNFH = "znfh" @@ -348,6 +411,20 @@ class DeviceCategory(StrEnum): """Wake Up Light II (undocumented)""" JDCLJQR = "jdcljqr" """Curtain Robot (undocumented)""" + JQBJ = "jqbj" + """Formaldehyde Detector (undocumented)""" + QXJ = "qxj" + """Temperature and Humidity Sensor with External Probe (undocumented) + + see https://github.com/home-assistant/core/issues/136472 + """ + VOC = "voc" + """Volatile Organic Compound Sensor (undocumented)""" + WG2 = "wg2" # Documented, but not in official list + """Gateway control + + https://developer.tuya.com/en/docs/iot/wg?id=Kbcdadk79ejok + """ WKF = "wkf" """Thermostatic Radiator Valve (undocumented)""" From 2d01a99ec27cac26856800c078540e9d2a062445 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 24 Sep 2025 13:44:33 +0200 Subject: [PATCH 112/189] Bump renault-api to 0.4.1 (#152883) --- homeassistant/components/renault/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index 9fe01c5b9529..82b6f82867d1 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["renault_api"], "quality_scale": "silver", - "requirements": ["renault-api==0.4.0"] + "requirements": ["renault-api==0.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 28e8de55eb6b..9d01eb29d456 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2685,7 +2685,7 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.4.0 +renault-api==0.4.1 # homeassistant.components.renson renson-endura-delta==1.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 535a8812f3af..079d13eb4eb0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2234,7 +2234,7 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.4.0 +renault-api==0.4.1 # homeassistant.components.renson renson-endura-delta==1.7.2 From a2f4073d545000ffb4ce5fc8d01b2e44bf5870b8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 24 Sep 2025 14:40:25 +0200 Subject: [PATCH 113/189] Use DeviceCategory in Tuya more platforms (#152885) --- homeassistant/components/tuya/const.py | 166 +++++++++++++++++--- homeassistant/components/tuya/event.py | 9 +- homeassistant/components/tuya/fan.py | 27 +--- homeassistant/components/tuya/humidifier.py | 12 +- homeassistant/components/tuya/light.py | 126 ++++----------- homeassistant/components/tuya/number.py | 100 ++++-------- homeassistant/components/tuya/select.py | 99 ++++-------- homeassistant/components/tuya/siren.py | 25 +-- homeassistant/components/tuya/vacuum.py | 4 +- homeassistant/components/tuya/valve.py | 10 +- 10 files changed, 257 insertions(+), 321 deletions(-) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index e3765e2d2ca7..158494946028 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -103,7 +103,10 @@ class DeviceCategory(StrEnum): BGL = "bgl" """Wall-hung boiler""" BH = "bh" - """Smart kettle""" + """Smart kettle + + https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 + """ BX = "bx" """Refrigerator""" BXX = "bxx" @@ -163,34 +166,58 @@ class DeviceCategory(StrEnum): https://developer.tuya.com/en/docs/iot/f?id=Kacpeobojffop """ DC = "dc" - """String lights""" + """String lights + + # https://developer.tuya.com/en/docs/iot/dc?id=Kaof7taxmvadu + """ DCL = "dcl" """Induction cooker""" DD = "dd" - """Strip lights""" + """Strip lights + + https://developer.tuya.com/en/docs/iot/dd?id=Kaof804aibg2l + """ DGNBJ = "dgnbj" """Multi-functional alarm https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 """ DJ = "dj" - """Light""" + """Light + + https://developer.tuya.com/en/docs/iot/categorydj?id=Kaiuyzy3eheyy + """ DLQ = "dlq" """Circuit breaker""" DR = "dr" - """Electric blanket""" + """Electric blanket + + https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p + """ DS = "ds" """TV set""" FS = "fs" - """Fan""" + """Fan + + https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + """ FSD = "fsd" - """Ceiling fan light""" + """Ceiling fan light + + https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v + """ FWD = "fwd" - """Ambiance light""" + """Ambiance light + + https://developer.tuya.com/en/docs/iot/ambient-light?id=Kaiuz06amhe6g + """ GGQ = "ggq" """Irrigator""" GYD = "gyd" - """Motion sensor light""" + """Motion sensor light + + https://developer.tuya.com/en/docs/iot/gyd?id=Kaof8a8hycfmy + """ GYMS = "gyms" """Business lock""" HOTELMS = "hotelms" @@ -203,7 +230,10 @@ class DeviceCategory(StrEnum): JS = "js" """Water purifier""" JSQ = "jsq" - """Humidifier""" + """Humidifier + + https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + """ JTMSBH = "jtmsbh" """Smart lock (keep alive)""" JTMSPRO = "jtmspro" @@ -214,11 +244,20 @@ class DeviceCategory(StrEnum): https://developer.tuya.com/en/docs/iot/categoryjwbj?id=Kaiuz40u98lkm """ KFJ = "kfj" - """Coffee maker""" + """Coffee maker + + https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f + """ KG = "kg" - """Switch""" + """Switch + + https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s + """ KJ = "kj" - """Air purifier""" + """Air purifier + + https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm + """ KQZG = "kqzg" """Air fryer""" KT = "kt" @@ -270,13 +309,19 @@ class DeviceCategory(StrEnum): MSP = "msp" """Cat toilet""" MZJ = "mzj" - """Sous vide cooker""" + """Sous vide cooker + + https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux + """ NNQ = "nnq" """Bottle warmer""" NTQ = "ntq" """HVAC""" PC = "pc" - """Power strip""" + """Power strip + + https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s + """ PHOTOLOCK = "photolock" """Audio and video lock""" PIR = "pir" @@ -292,7 +337,7 @@ class DeviceCategory(StrEnum): QN = "qn" """Heater - https://developer.tuya.com/en/docs/iot/f?id=K9gf46epy4j82 + https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm """ RQBJ = "rqbj" """Gas alarm @@ -331,14 +376,23 @@ class DeviceCategory(StrEnum): SP = "sp" """Smart camera - https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu + https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 """ SZ = "sz" - """Smart indoor garden""" + """Smart indoor garden + + https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0 + """ TGKG = "tgkg" - """Dimmer switch""" + """Dimmer switch + + https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o + """ TGQ = "tgq" - """Dimmer""" + """Dimmer + + https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o + """ TNQ = "tnq" """Smart milk kettle""" TRACKER = "tracker" @@ -346,7 +400,10 @@ class DeviceCategory(StrEnum): TS = "ts" """Smart jump rope""" TYNDJ = "tyndj" - """Solar light""" + """Solar light + + https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 + """ TYY = "tyy" """Projector""" TZC1 = "tzc1" @@ -364,7 +421,10 @@ class DeviceCategory(StrEnum): https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 """ XDD = "xdd" - """Ceiling light""" + """Ceiling light + + https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r + """ XFJ = "xfj" """Ventilation system""" XXJ = "xxj" @@ -376,7 +436,10 @@ class DeviceCategory(StrEnum): YG = "yg" """Bathtub""" YKQ = "ykq" - """Remote control""" + """Remote control + + https://developer.tuya.com/en/docs/iot/ykq?id=Kaof8ljn81aov + """ YLCG = "ylcg" """Pressure sensor @@ -402,22 +465,65 @@ class DeviceCategory(StrEnum): """Smart pill box""" # Undocumented + BZYD = "bzyd" + """White noise machine (undocumented)""" + CWJWQ = "cwjwq" + """Smart Odor Eliminator-Pro (undocumented) + + see https://github.com/orgs/home-assistant/discussions/79 + """ DGHSXJ = "dghsxj" """Smart Camera - Low power consumption camera (undocumented) see https://github.com/home-assistant/core/issues/132844 """ + DSD = "dsd" + """Filament Light + + Based on data from https://github.com/home-assistant/core/issues/106703 + Product category mentioned in https://developer.tuya.com/en/docs/iot/oemapp-light?id=Kb77kja5woao6 + As at 30/12/23 not documented in https://developer.tuya.com/en/docs/iot/lighting?id=Kaiuyzxq30wmc + """ + FSKG = "fskg" + """Fan wall switch (undocumented)""" HXD = "hxd" """Wake Up Light II (undocumented)""" JDCLJQR = "jdcljqr" """Curtain Robot (undocumented)""" JQBJ = "jqbj" """Formaldehyde Detector (undocumented)""" + KS = "ks" + """Tower fan (undocumented) + + See https://github.com/orgs/home-assistant/discussions/329 + """ + MBD = "mbd" + """Unknown light product + + Found as VECINO RGBW as provided by diagnostics + """ + QJDCZ = "qjdcz" + """ Unknown product with light capabilities + + Found in some diffusers, plugs and PIR flood lights + """ QXJ = "qxj" """Temperature and Humidity Sensor with External Probe (undocumented) see https://github.com/home-assistant/core/issues/136472 """ + SFKZQ = "sfkzq" + """Smart Water Timer (undocumented)""" + SJZ = "sjz" + """Electric desk (undocumented)""" + SZJQR = "szjqr" + """Fingerbot (undocumented)""" + SWTZ = "swtz" + """Cooking thermometer (undocumented)""" + TDQ = "tdq" + """Dimmer (undocumented)""" + TYD = "tyd" + """Outdoor flood light (undocumented)""" VOC = "voc" """Volatile Organic Compound Sensor (undocumented)""" WG2 = "wg2" # Documented, but not in official list @@ -427,6 +533,20 @@ class DeviceCategory(StrEnum): """ WKF = "wkf" """Thermostatic Radiator Valve (undocumented)""" + WXKG = "wxkg" # Documented, but not in official list + """Wireless Switch + + https://developer.tuya.com/en/docs/iot/s?id=Kbeoa9fkv6brp + """ + XNYJCN = "xnyjcn" + """Micro Storage Inverter + + Energy storage and solar PV inverter system with monitoring capabilities + """ + YWCGQ = "ywcgq" + """Tank Level Sensor (undocumented)""" + ZNRB = "znrb" + """Pool HeatPump""" class DPCode(StrEnum): diff --git a/homeassistant/components/tuya/event.py b/homeassistant/components/tuya/event.py index 5eda6cbe6bbe..4cfb22e4cce4 100644 --- a/homeassistant/components/tuya/event.py +++ b/homeassistant/components/tuya/event.py @@ -14,17 +14,14 @@ 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, DPType +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity # All descriptions can be found here. Mostly the Enum data types in the # default status set of each category (that don't have a set instruction) # end up being events. -# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -EVENTS: dict[str, tuple[EventEntityDescription, ...]] = { - # Wireless Switch - # https://developer.tuya.com/en/docs/iot/s?id=Kbeoa9fkv6brp - "wxkg": ( +EVENTS: dict[DeviceCategory, tuple[EventEntityDescription, ...]] = { + DeviceCategory.WXKG: ( EventEntityDescription( key=DPCode.SWITCH_MODE1, device_class=EventDeviceClass.BUTTON, diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index dc6d234cc5d4..db16720ddc42 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -21,7 +21,7 @@ from homeassistant.util.percentage import ( ) from . import TuyaConfigEntry -from .const import TUYA_DISCOVERY_NEW, DPCode, DPType +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity from .models import EnumTypeData, IntegerTypeData from .util import get_dpcode @@ -36,24 +36,13 @@ _SPEED_DPCODES = ( ) _SWITCH_DPCODES = (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH) -TUYA_SUPPORT_TYPE = { - # 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", +TUYA_SUPPORT_TYPE: set[DeviceCategory] = { + DeviceCategory.CS, + DeviceCategory.FS, + DeviceCategory.FSD, + DeviceCategory.FSKG, + DeviceCategory.KJ, + DeviceCategory.KS, } diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index 3d90ff3b44ff..cc6fdd778fe8 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -18,7 +18,7 @@ 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, DPType +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity from .models import IntegerTypeData from .util import ActionDPCodeNotFoundError, get_dpcode @@ -49,19 +49,15 @@ def _has_a_valid_dpcode( 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 - "cs": TuyaHumidifierEntityDescription( +HUMIDIFIERS: dict[DeviceCategory, TuyaHumidifierEntityDescription] = { + DeviceCategory.CS: TuyaHumidifierEntityDescription( key=DPCode.SWITCH, dpcode=(DPCode.SWITCH, DPCode.SWITCH_SPRAY), current_humidity=DPCode.HUMIDITY_INDOOR, humidity=DPCode.DEHUMIDITY_SET_VALUE, device_class=HumidifierDeviceClass.DEHUMIDIFIER, ), - # Humidifier - # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b - "jsq": TuyaHumidifierEntityDescription( + DeviceCategory.JSQ: TuyaHumidifierEntityDescription( key=DPCode.SWITCH, dpcode=(DPCode.SWITCH, DPCode.SWITCH_SPRAY), current_humidity=DPCode.HUMIDITY_CURRENT, diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 6b1ac3e991fb..d2cceaa46204 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -26,7 +26,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from . import TuyaConfigEntry -from .const import TUYA_DISCOVERY_NEW, DPCode, DPType, WorkMode +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType, WorkMode from .entity import TuyaEntity from .models import IntegerTypeData from .util import get_dpcode, remap_value @@ -72,9 +72,8 @@ class TuyaLightEntityDescription(LightEntityDescription): ) -LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { - # White noise machine - "bzyd": ( +LIGHTS: dict[DeviceCategory, tuple[TuyaLightEntityDescription, ...]] = { + DeviceCategory.BZYD: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -82,18 +81,14 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { color_data=DPCode.COLOUR_DATA, ), ), - # Curtain Switch - # https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39 - "clkg": ( + DeviceCategory.CLKG: ( TuyaLightEntityDescription( key=DPCode.SWITCH_BACKLIGHT, translation_key="backlight", entity_category=EntityCategory.CONFIG, ), ), - # String Lights - # https://developer.tuya.com/en/docs/iot/dc?id=Kaof7taxmvadu - "dc": ( + DeviceCategory.DC: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -103,9 +98,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { color_data=DPCode.COLOUR_DATA, ), ), - # Strip Lights - # https://developer.tuya.com/en/docs/iot/dd?id=Kaof804aibg2l - "dd": ( + DeviceCategory.DD: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -116,9 +109,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { default_color_type=DEFAULT_COLOR_TYPE_DATA_V2, ), ), - # Light - # https://developer.tuya.com/en/docs/iot/categorydj?id=Kaiuyzy3eheyy - "dj": ( + DeviceCategory.DJ: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -136,11 +127,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { brightness=DPCode.BRIGHT_VALUE_1, ), ), - # Filament Light - # Based on data from https://github.com/home-assistant/core/issues/106703 - # Product category mentioned in https://developer.tuya.com/en/docs/iot/oemapp-light?id=Kb77kja5woao6 - # As at 30/12/23 not documented in https://developer.tuya.com/en/docs/iot/lighting?id=Kaiuyzxq30wmc - "dsd": ( + DeviceCategory.DSD: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -148,9 +135,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { brightness=DPCode.BRIGHT_VALUE, ), ), - # Fan - # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c - "fs": ( + DeviceCategory.FS: ( TuyaLightEntityDescription( key=DPCode.LIGHT, name=None, @@ -165,9 +150,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { brightness=DPCode.BRIGHT_VALUE_1, ), ), - # Ceiling Fan Light - # https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v - "fsd": ( + DeviceCategory.FSD: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -182,9 +165,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { name=None, ), ), - # Ambient Light - # https://developer.tuya.com/en/docs/iot/ambient-light?id=Kaiuz06amhe6g - "fwd": ( + DeviceCategory.FWD: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -194,9 +175,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { color_data=DPCode.COLOUR_DATA, ), ), - # Motion Sensor Light - # https://developer.tuya.com/en/docs/iot/gyd?id=Kaof8a8hycfmy - "gyd": ( + DeviceCategory.GYD: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -206,9 +185,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { color_data=DPCode.COLOUR_DATA, ), ), - # Wake Up Light II - # Not documented - "hxd": ( + DeviceCategory.HXD: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, translation_key="light", @@ -217,9 +194,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { brightness_min=DPCode.BRIGHTNESS_MIN_1, ), ), - # Humidifier Light - # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b - "jsq": ( + DeviceCategory.JSQ: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -228,46 +203,35 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { color_data=DPCode.COLOUR_DATA_HSV, ), ), - # Switch - # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s - "kg": ( + DeviceCategory.KG: ( TuyaLightEntityDescription( key=DPCode.SWITCH_BACKLIGHT, translation_key="backlight", entity_category=EntityCategory.CONFIG, ), ), - # Air Purifier - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm - "kj": ( + DeviceCategory.KJ: ( TuyaLightEntityDescription( key=DPCode.LIGHT, translation_key="backlight", entity_category=EntityCategory.CONFIG, ), ), - # Air conditioner - # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n - "kt": ( + DeviceCategory.KT: ( TuyaLightEntityDescription( key=DPCode.LIGHT, translation_key="backlight", entity_category=EntityCategory.CONFIG, ), ), - # Undocumented tower fan - # https://github.com/orgs/home-assistant/discussions/329 - "ks": ( + DeviceCategory.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 - "mbd": ( + DeviceCategory.MBD: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -276,10 +240,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { color_data=DPCode.COLOUR_DATA, ), ), - # Unknown product with light capabilities - # Fond in some diffusers, plugs and PIR flood lights - # Not documented - "qjdcz": ( + DeviceCategory.QJDCZ: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -288,18 +249,14 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { color_data=DPCode.COLOUR_DATA, ), ), - # Heater - # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm - "qn": ( + DeviceCategory.QN: ( TuyaLightEntityDescription( key=DPCode.LIGHT, translation_key="backlight", entity_category=EntityCategory.CONFIG, ), ), - # Smart Camera - # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 - "sp": ( + DeviceCategory.SP: ( TuyaLightEntityDescription( key=DPCode.FLOODLIGHT_SWITCH, brightness=DPCode.FLOODLIGHT_LIGHTNESS, @@ -311,18 +268,14 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Smart Gardening system - # https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0 - "sz": ( + DeviceCategory.SZ: ( TuyaLightEntityDescription( key=DPCode.LIGHT, brightness=DPCode.BRIGHT_VALUE, translation_key="light", ), ), - # Dimmer Switch - # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o - "tgkg": ( + DeviceCategory.TGKG: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED_1, translation_key="indexed_light", @@ -348,9 +301,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { brightness_min=DPCode.BRIGHTNESS_MIN_3, ), ), - # Dimmer - # https://developer.tuya.com/en/docs/iot/tgq?id=Kaof8ke9il4k4 - "tgq": ( + DeviceCategory.TGQ: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, translation_key="light", @@ -371,9 +322,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { brightness=DPCode.BRIGHT_VALUE_2, ), ), - # Outdoor Flood Light - # Not documented - "tyd": ( + DeviceCategory.TYD: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -383,9 +332,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { color_data=DPCode.COLOUR_DATA, ), ), - # Solar Light - # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 - "tyndj": ( + DeviceCategory.TYNDJ: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -395,9 +342,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { color_data=DPCode.COLOUR_DATA, ), ), - # Ceiling Light - # https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r - "xdd": ( + DeviceCategory.XDD: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -411,9 +356,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { translation_key="night_light", ), ), - # Remote Control - # https://developer.tuya.com/en/docs/iot/ykq?id=Kaof8ljn81aov - "ykq": ( + DeviceCategory.YKQ: ( TuyaLightEntityDescription( key=DPCode.SWITCH_CONTROLLER, name=None, @@ -426,19 +369,16 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { # Socket (duplicate of `kg`) # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s -LIGHTS["cz"] = LIGHTS["kg"] +LIGHTS[DeviceCategory.CZ] = LIGHTS[DeviceCategory.KG] # Power Socket (duplicate of `kg`) -# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s -LIGHTS["pc"] = LIGHTS["kg"] +LIGHTS[DeviceCategory.PC] = LIGHTS[DeviceCategory.KG] # Smart Camera - Low power consumption camera (duplicate of `sp`) -# Undocumented, see https://github.com/home-assistant/core/issues/132844 -LIGHTS["dghsxj"] = LIGHTS["sp"] +LIGHTS[DeviceCategory.DGHSXJ] = LIGHTS[DeviceCategory.SP] # Dimmer (duplicate of `tgq`) -# https://developer.tuya.com/en/docs/iot/tgq?id=Kaof8ke9il4k4 -LIGHTS["tdq"] = LIGHTS["tgq"] +LIGHTS[DeviceCategory.TDQ] = LIGHTS[DeviceCategory.TGQ] @dataclass diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 30c1c03807e4..1fb00a4de514 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -21,6 +21,7 @@ from .const import ( DOMAIN, LOGGER, TUYA_DISCOVERY_NEW, + DeviceCategory, DPCode, DPType, ) @@ -28,13 +29,8 @@ 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. -# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { - # Smart Kettle - # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 - "bh": ( +NUMBERS: dict[DeviceCategory, tuple[NumberEntityDescription, ...]] = { + DeviceCategory.BH: ( NumberEntityDescription( key=DPCode.TEMP_SET, translation_key="temperature", @@ -65,17 +61,14 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # White noise machine - "bzyd": ( + DeviceCategory.BZYD: ( NumberEntityDescription( key=DPCode.VOLUME_SET, translation_key="volume", entity_category=EntityCategory.CONFIG, ), ), - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( + DeviceCategory.CO2BJ: ( NumberEntityDescription( key=DPCode.ALARM_TIME, translation_key="alarm_duration", @@ -84,9 +77,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Smart Pet Feeder - # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld - "cwwsq": ( + DeviceCategory.CWWSQ: ( NumberEntityDescription( key=DPCode.MANUAL_FEED, translation_key="feed", @@ -96,27 +87,21 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { translation_key="voice_times", ), ), - # Multi-functional Sensor - # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 - "dgnbj": ( + DeviceCategory.DGNBJ: ( NumberEntityDescription( key=DPCode.ALARM_TIME, translation_key="time", entity_category=EntityCategory.CONFIG, ), ), - # Fan - # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c - "fs": ( + DeviceCategory.FS: ( NumberEntityDescription( key=DPCode.TEMP, translation_key="temperature", device_class=NumberDeviceClass.TEMPERATURE, ), ), - # Human Presence Sensor - # https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs - "hps": ( + DeviceCategory.HPS: ( NumberEntityDescription( key=DPCode.SENSITIVITY, translation_key="sensitivity", @@ -140,9 +125,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { device_class=NumberDeviceClass.DISTANCE, ), ), - # Humidifier - # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b - "jsq": ( + DeviceCategory.JSQ: ( NumberEntityDescription( key=DPCode.TEMP_SET, translation_key="temperature", @@ -154,9 +137,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { device_class=NumberDeviceClass.TEMPERATURE, ), ), - # Coffee maker - # https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f - "kfj": ( + DeviceCategory.KFJ: ( NumberEntityDescription( key=DPCode.WATER_SET, translation_key="water_level", @@ -179,9 +160,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Alarm Host - # https://developer.tuya.com/en/docs/iot/alarm-hosts?id=K9gf48r87hyjk - "mal": ( + DeviceCategory.MAL: ( NumberEntityDescription( key=DPCode.DELAY_SET, # This setting is called "Arm Delay" in the official Tuya app @@ -203,9 +182,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Sous Vide Cooker - # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux - "mzj": ( + DeviceCategory.MZJ: ( NumberEntityDescription( key=DPCode.COOK_TEMPERATURE, translation_key="cook_temperature", @@ -223,8 +200,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Cooking thermometer - "swtz": ( + DeviceCategory.SWTZ: ( NumberEntityDescription( key=DPCode.COOK_TEMPERATURE, translation_key="cook_temperature", @@ -237,17 +213,14 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Robot Vacuum - # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo - "sd": ( + DeviceCategory.SD: ( NumberEntityDescription( key=DPCode.VOLUME_SET, translation_key="volume", entity_category=EntityCategory.CONFIG, ), ), - # Smart Water Timer - "sfkzq": ( + DeviceCategory.SFKZQ: ( # Controls the irrigation duration for the water valve NumberEntityDescription( key=DPCode.COUNTDOWN_1, @@ -306,26 +279,21 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Siren Alarm - # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu - "sgbj": ( + DeviceCategory.SGBJ: ( NumberEntityDescription( key=DPCode.ALARM_TIME, translation_key="time", entity_category=EntityCategory.CONFIG, ), ), - # Smart Camera - # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 - "sp": ( + DeviceCategory.SP: ( NumberEntityDescription( key=DPCode.BASIC_DEVICE_VOLUME, translation_key="volume", entity_category=EntityCategory.CONFIG, ), ), - # Fingerbot - "szjqr": ( + DeviceCategory.SZJQR: ( NumberEntityDescription( key=DPCode.ARM_DOWN_PERCENT, translation_key="move_down", @@ -344,9 +312,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Dimmer Switch - # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o - "tgkg": ( + DeviceCategory.TGKG: ( NumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_1, translation_key="indexed_minimum_brightness", @@ -384,9 +350,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Dimmer Switch - # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o - "tgq": ( + DeviceCategory.TGQ: ( NumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_1, translation_key="indexed_minimum_brightness", @@ -412,18 +376,14 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Thermostat - # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 - "wk": ( + DeviceCategory.WK: ( NumberEntityDescription( key=DPCode.TEMP_CORRECTION, translation_key="temp_correction", entity_category=EntityCategory.CONFIG, ), ), - # Micro Storage Inverter - # Energy storage and solar PV inverter system with monitoring capabilities - "xnyjcn": ( + DeviceCategory.XNYJCN: ( NumberEntityDescription( key=DPCode.BACKUP_RESERVE, translation_key="battery_backup_reserve", @@ -436,9 +396,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Tank Level Sensor - # Note: Undocumented - "ywcgq": ( + DeviceCategory.YWCGQ: ( NumberEntityDescription( key=DPCode.MAX_SET, translation_key="alarm_maximum", @@ -462,17 +420,14 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Vibration Sensor - # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno - "zd": ( + DeviceCategory.ZD: ( NumberEntityDescription( key=DPCode.SENSITIVITY, translation_key="sensitivity", entity_category=EntityCategory.CONFIG, ), ), - # Pool HeatPump - "znrb": ( + DeviceCategory.ZNRB: ( NumberEntityDescription( key=DPCode.TEMP_SET, translation_key="temperature", @@ -482,8 +437,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { } # Smart Camera - Low power consumption camera (duplicate of `sp`) -# Undocumented, see https://github.com/home-assistant/core/issues/132844 -NUMBERS["dghsxj"] = NUMBERS["sp"] +NUMBERS[DeviceCategory.DGHSXJ] = NUMBERS[DeviceCategory.SP] async def async_setup_entry( diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index e16642305e7f..6a4d8d7b4883 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -11,16 +11,13 @@ 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, DPType +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity # All descriptions can be found here. Mostly the Enum data types in the # default instructions set of each category end up being a select. -# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { - # Curtain - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc - "cl": ( +SELECTS: dict[DeviceCategory, tuple[SelectEntityDescription, ...]] = { + DeviceCategory.CL: ( SelectEntityDescription( key=DPCode.CONTROL_BACK_MODE, entity_category=EntityCategory.CONFIG, @@ -32,18 +29,14 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="curtain_mode", ), ), - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( + DeviceCategory.CO2BJ: ( SelectEntityDescription( key=DPCode.ALARM_VOLUME, translation_key="volume", entity_category=EntityCategory.CONFIG, ), ), - # Dehumidifier - # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha - "cs": ( + DeviceCategory.CS: ( SelectEntityDescription( key=DPCode.COUNTDOWN_SET, entity_category=EntityCategory.CONFIG, @@ -55,27 +48,21 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Smart Odor Eliminator-Pro - # Undocumented, see https://github.com/orgs/home-assistant/discussions/79 - "cwjwq": ( + DeviceCategory.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": ( + DeviceCategory.DGNBJ: ( SelectEntityDescription( key=DPCode.ALARM_VOLUME, translation_key="volume", entity_category=EntityCategory.CONFIG, ), ), - # Electric Blanket - # https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p - "dr": ( + DeviceCategory.DR: ( SelectEntityDescription( key=DPCode.LEVEL, icon="mdi:thermometer-lines", @@ -94,9 +81,7 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_placeholders={"index": "2"}, ), ), - # Fan - # https://developer.tuya.com/en/docs/iot/f?id=K9gf45vs7vkge - "fs": ( + DeviceCategory.FS: ( SelectEntityDescription( key=DPCode.FAN_VERTICAL, entity_category=EntityCategory.CONFIG, @@ -118,9 +103,7 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="countdown", ), ), - # Humidifier - # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b - "jsq": ( + DeviceCategory.JSQ: ( SelectEntityDescription( key=DPCode.SPRAY_MODE, entity_category=EntityCategory.CONFIG, @@ -147,9 +130,7 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="countdown", ), ), - # Coffee maker - # https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f - "kfj": ( + DeviceCategory.KFJ: ( SelectEntityDescription( key=DPCode.CUP_NUMBER, translation_key="cups", @@ -169,9 +150,7 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="mode", ), ), - # Switch - # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s - "kg": ( + DeviceCategory.KG: ( SelectEntityDescription( key=DPCode.RELAY_STATUS, entity_category=EntityCategory.CONFIG, @@ -183,9 +162,7 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="light_mode", ), ), - # Air Purifier - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm - "kj": ( + DeviceCategory.KJ: ( SelectEntityDescription( key=DPCode.COUNTDOWN, entity_category=EntityCategory.CONFIG, @@ -197,17 +174,13 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="countdown", ), ), - # Heater - # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm - "qn": ( + DeviceCategory.QN: ( SelectEntityDescription( key=DPCode.LEVEL, translation_key="temperature_level", ), ), - # Robot Vacuum - # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo - "sd": ( + DeviceCategory.SD: ( SelectEntityDescription( key=DPCode.CISTERN, entity_category=EntityCategory.CONFIG, @@ -224,8 +197,7 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="vacuum_mode", ), ), - # Smart Water Timer - "sfkzq": ( + DeviceCategory.SFKZQ: ( # Irrigation will not be run within this set delay period SelectEntityDescription( key=DPCode.WEATHER_DELAY, @@ -233,9 +205,7 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Siren Alarm - # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu - "sgbj": ( + DeviceCategory.SGBJ: ( SelectEntityDescription( key=DPCode.ALARM_VOLUME, translation_key="volume", @@ -247,8 +217,7 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Electric desk - "sjz": ( + DeviceCategory.SJZ: ( SelectEntityDescription( key=DPCode.LEVEL, translation_key="desk_level", @@ -260,9 +229,7 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Smart Camera - # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 - "sp": ( + DeviceCategory.SP: ( SelectEntityDescription( key=DPCode.IPC_WORK_MODE, entity_category=EntityCategory.CONFIG, @@ -294,17 +261,14 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="motion_sensitivity", ), ), - # Fingerbot - "szjqr": ( + DeviceCategory.SZJQR: ( SelectEntityDescription( key=DPCode.MODE, entity_category=EntityCategory.CONFIG, translation_key="fingerbot_mode", ), ), - # IoT Switch? - # Note: Undocumented - "tdq": ( + DeviceCategory.TDQ: ( SelectEntityDescription( key=DPCode.RELAY_STATUS, entity_category=EntityCategory.CONFIG, @@ -316,9 +280,7 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="light_mode", ), ), - # Dimmer Switch - # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o - "tgkg": ( + DeviceCategory.TGKG: ( SelectEntityDescription( key=DPCode.RELAY_STATUS, entity_category=EntityCategory.CONFIG, @@ -348,9 +310,7 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_placeholders={"index": "3"}, ), ), - # Dimmer - # https://developer.tuya.com/en/docs/iot/tgq?id=Kaof8ke9il4k4 - "tgq": ( + DeviceCategory.TGQ: ( SelectEntityDescription( key=DPCode.LED_TYPE_1, entity_category=EntityCategory.CONFIG, @@ -364,9 +324,7 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_placeholders={"index": "2"}, ), ), - # Micro Storage Inverter - # Energy storage and solar PV inverter system with monitoring capabilities - "xnyjcn": ( + DeviceCategory.XNYJCN: ( SelectEntityDescription( key=DPCode.WORK_MODE, translation_key="inverter_work_mode", @@ -376,16 +334,13 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { } # Socket (duplicate of `kg`) -# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s -SELECTS["cz"] = SELECTS["kg"] +SELECTS[DeviceCategory.CZ] = SELECTS[DeviceCategory.KG] # Smart Camera - Low power consumption camera (duplicate of `sp`) -# Undocumented, see https://github.com/home-assistant/core/issues/132844 -SELECTS["dghsxj"] = SELECTS["sp"] +SELECTS[DeviceCategory.DGHSXJ] = SELECTS[DeviceCategory.SP] # Power Socket (duplicate of `kg`) -# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s -SELECTS["pc"] = SELECTS["kg"] +SELECTS[DeviceCategory.PC] = SELECTS[DeviceCategory.KG] async def async_setup_entry( diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py index e6849eb767ee..8c29684ba9f1 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -17,37 +17,27 @@ 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 .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -# All descriptions can be found here: -# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = { - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( +SIRENS: dict[DeviceCategory, tuple[SirenEntityDescription, ...]] = { + DeviceCategory.CO2BJ: ( SirenEntityDescription( key=DPCode.ALARM_SWITCH, entity_category=EntityCategory.CONFIG, ), ), - # Multi-functional Sensor - # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 - "dgnbj": ( + DeviceCategory.DGNBJ: ( SirenEntityDescription( key=DPCode.ALARM_SWITCH, ), ), - # Siren Alarm - # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu - "sgbj": ( + DeviceCategory.SGBJ: ( SirenEntityDescription( key=DPCode.ALARM_SWITCH, ), ), - # Smart Camera - # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 - "sp": ( + DeviceCategory.SP: ( SirenEntityDescription( key=DPCode.SIREN_SWITCH, ), @@ -55,8 +45,7 @@ SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = { } # Smart Camera - Low power consumption camera (duplicate of `sp`) -# Undocumented, see https://github.com/home-assistant/core/issues/132844 -SIRENS["dghsxj"] = SIRENS["sp"] +SIRENS[DeviceCategory.DGHSXJ] = SIRENS[DeviceCategory.SP] async def async_setup_entry( diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index 0d5ea1ee70da..8e0674ad23a9 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -16,7 +16,7 @@ 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, DPType +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity from .models import EnumTypeData from .util import get_dpcode @@ -63,7 +63,7 @@ async def async_setup_entry( entities: list[TuyaVacuumEntity] = [] for device_id in device_ids: device = manager.device_map[device_id] - if device.category == "sd": + if device.category == DeviceCategory.SD: entities.append(TuyaVacuumEntity(device, manager)) async_add_entities(entities) diff --git a/homeassistant/components/tuya/valve.py b/homeassistant/components/tuya/valve.py index dcb63c00cc93..f14d605c19a1 100644 --- a/homeassistant/components/tuya/valve.py +++ b/homeassistant/components/tuya/valve.py @@ -15,15 +15,11 @@ 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 .const import TUYA_DISCOVERY_NEW, DeviceCategory, 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": ( +VALVES: dict[DeviceCategory, tuple[ValveEntityDescription, ...]] = { + DeviceCategory.SFKZQ: ( ValveEntityDescription( key=DPCode.SWITCH, translation_key="valve", From fdaceaddfdc749b83fd7bc4efe3b72d81e2a5a5e Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 24 Sep 2025 14:57:22 +0200 Subject: [PATCH 114/189] Add new virtual integration Neo (#152886) --- homeassistant/components/neo/__init__.py | 1 + homeassistant/components/neo/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/neo/__init__.py create mode 100644 homeassistant/components/neo/manifest.json diff --git a/homeassistant/components/neo/__init__.py b/homeassistant/components/neo/__init__.py new file mode 100644 index 000000000000..613f57c07031 --- /dev/null +++ b/homeassistant/components/neo/__init__.py @@ -0,0 +1 @@ +"""Neo virtual integration.""" diff --git a/homeassistant/components/neo/manifest.json b/homeassistant/components/neo/manifest.json new file mode 100644 index 000000000000..9f934a603098 --- /dev/null +++ b/homeassistant/components/neo/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "neo", + "name": "Neo", + "integration_type": "virtual", + "supported_by": "shelly" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f060e3cb96e7..fb4d3a199215 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4306,6 +4306,11 @@ "integration_type": "virtual", "supported_by": "home_connect" }, + "neo": { + "name": "Neo", + "integration_type": "virtual", + "supported_by": "shelly" + }, "ness_alarm": { "name": "Ness Alarm", "integration_type": "hub", From c493c7dd674ae660e0e6d320ef756cb2a48f1daf Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 24 Sep 2025 09:24:42 -0500 Subject: [PATCH 115/189] Bump intents and fix tests (#152893) --- 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/conversation/test_default_agent.py | 2 +- .../conversation/test_default_agent_intents.py | 10 +++++----- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 8101f8c8b5f6..b3bc9b8c067e 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": "entity", "quality_scale": "internal", - "requirements": ["hassil==3.2.0", "home-assistant-intents==2025.9.3"] + "requirements": ["hassil==3.2.0", "home-assistant-intents==2025.9.24"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 227b9e3b9188..afc46ecbd6bb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -40,7 +40,7 @@ hass-nabucasa==1.1.1 hassil==3.2.0 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250903.5 -home-assistant-intents==2025.9.3 +home-assistant-intents==2025.9.24 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 diff --git a/requirements_all.txt b/requirements_all.txt index 9d01eb29d456..5500f3385a32 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1189,7 +1189,7 @@ holidays==0.81 home-assistant-frontend==20250903.5 # homeassistant.components.conversation -home-assistant-intents==2025.9.3 +home-assistant-intents==2025.9.24 # homeassistant.components.homematicip_cloud homematicip==2.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 079d13eb4eb0..4f0bf24d867e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1038,7 +1038,7 @@ holidays==0.81 home-assistant-frontend==20250903.5 # homeassistant.components.conversation -home-assistant-intents==2025.9.3 +home-assistant-intents==2025.9.24 # homeassistant.components.homematicip_cloud homematicip==2.3.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 18550535fbe7..a9f0aacdae10 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -32,7 +32,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.8.9,source=/uv,target=/bin/uv \ go2rtc-client==0.2.1 \ ha-ffmpeg==3.2.2 \ hassil==3.2.0 \ - home-assistant-intents==2025.9.3 \ + home-assistant-intents==2025.9.24 \ mutagen==1.47.0 \ pymicro-vad==1.0.1 \ pyspeex-noise==1.0.2 diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 2db9dd9fc36e..8356274a41ea 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -2542,7 +2542,7 @@ async def test_non_default_response(hass: HomeAssistant, init_components) -> Non ) ) assert len(calls) == 1 - assert result.response.speech["plain"]["speech"] == "Opened" + assert result.response.speech["plain"]["speech"] == "Opening" async def test_turn_on_area( diff --git a/tests/components/conversation/test_default_agent_intents.py b/tests/components/conversation/test_default_agent_intents.py index 2b0e9f30190a..8828cc4bd1e6 100644 --- a/tests/components/conversation/test_default_agent_intents.py +++ b/tests/components/conversation/test_default_agent_intents.py @@ -90,7 +90,7 @@ async def test_cover_set_position( response = result.response assert response.response_type == intent.IntentResponseType.ACTION_DONE - assert response.speech["plain"]["speech"] == "Opened" + assert response.speech["plain"]["speech"] == "Opening" assert len(calls) == 1 call = calls[0] assert call.data == {"entity_id": entity_id} @@ -104,7 +104,7 @@ async def test_cover_set_position( response = result.response assert response.response_type == intent.IntentResponseType.ACTION_DONE - assert response.speech["plain"]["speech"] == "Closed" + assert response.speech["plain"]["speech"] == "Closing" assert len(calls) == 1 call = calls[0] assert call.data == {"entity_id": entity_id} @@ -146,7 +146,7 @@ async def test_cover_device_class( response = result.response assert response.response_type == intent.IntentResponseType.ACTION_DONE - assert response.speech["plain"]["speech"] == "Opened the garage" + assert response.speech["plain"]["speech"] == "Opening the garage" assert len(calls) == 1 call = calls[0] assert call.data == {"entity_id": entity_id} @@ -170,7 +170,7 @@ async def test_valve_intents( response = result.response assert response.response_type == intent.IntentResponseType.ACTION_DONE - assert response.speech["plain"]["speech"] == "Opened" + assert response.speech["plain"]["speech"] == "Opening" assert len(calls) == 1 call = calls[0] assert call.data == {"entity_id": entity_id} @@ -184,7 +184,7 @@ async def test_valve_intents( response = result.response assert response.response_type == intent.IntentResponseType.ACTION_DONE - assert response.speech["plain"]["speech"] == "Closed" + assert response.speech["plain"]["speech"] == "Closing" assert len(calls) == 1 call = calls[0] assert call.data == {"entity_id": entity_id} From 62cea48a583d69bcb47c2ffad75b8e58450a4e0b Mon Sep 17 00:00:00 2001 From: Richard Polzer Date: Wed, 24 Sep 2025 16:46:22 +0200 Subject: [PATCH 116/189] Fix typo in ekeybionyx strings.json (#152889) --- homeassistant/components/ekeybionyx/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ekeybionyx/strings.json b/homeassistant/components/ekeybionyx/strings.json index 525189d5a71f..14ad5de5aa49 100644 --- a/homeassistant/components/ekeybionyx/strings.json +++ b/homeassistant/components/ekeybionyx/strings.json @@ -37,7 +37,7 @@ } }, "progress": { - "check_deletion_status": "Please go to the {ekeybionyx} app and confirm the deletion of the functions." + "check_deletion_status": "Please open the {ekeybionyx} app and confirm the deletion of the functions." }, "error": { "invalid_name": "Name is invalid", @@ -55,7 +55,7 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", - "no_available_webhooks": "There are no available webhooks in the {ekeybionyx} plattform. Please delete some and try again.", + "no_available_webhooks": "There are no available webhooks in the {ekeybionyx} system. Please delete some and try again.", "no_own_systems": "Your account does not have admin access to any systems.", "cannot_connect": "Connection to {ekeybionyx} failed. Please check your Internet connection and try again." }, From dfbaf66021c59ae916f0e6a15b2b3d60a4559180 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 24 Sep 2025 18:18:42 +0300 Subject: [PATCH 117/189] Add progress step decorator for easier config flows (#152739) Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Co-authored-by: Martin Hjelmare --- .../firmware_config_flow.py | 97 ++++-------- homeassistant/data_entry_flow.py | 138 +++++++++++++++++- .../test_config_flow.py | 2 +- 3 files changed, 167 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 61678b11395c..98a2fb2f881a 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -28,7 +28,7 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.core import callback -from homeassistant.data_entry_flow import AbortFlow +from homeassistant.data_entry_flow import AbortFlow, progress_step from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio @@ -72,8 +72,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): """Base flow to install firmware.""" ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override - _failed_addon_name: str - _failed_addon_reason: str _picked_firmware_type: PickedFirmwareType def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -85,8 +83,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): self._hardware_name: str = "unknown" # To be set in a subclass self._zigbee_integration = ZigbeeIntegration.ZHA - self.addon_install_task: asyncio.Task | None = None - self.addon_start_task: asyncio.Task | None = None self.addon_uninstall_task: asyncio.Task | None = None self.firmware_install_task: asyncio.Task[None] | None = None self.installing_firmware_name: str | None = None @@ -486,18 +482,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): """Install Zigbee firmware.""" raise NotImplementedError - async def async_step_addon_operation_failed( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Abort when add-on installation or start failed.""" - return self.async_abort( - reason=self._failed_addon_reason, - description_placeholders={ - **self._get_translation_placeholders(), - "addon_name": self._failed_addon_name, - }, - ) - async def async_step_pre_confirm_zigbee( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -561,6 +545,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): """Install Thread firmware.""" raise NotImplementedError + @progress_step( + description_placeholders=lambda self: { + **self._get_translation_placeholders(), + "addon_name": get_otbr_addon_manager(self.hass).addon_name, + } + ) async def async_step_install_otbr_addon( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -570,70 +560,43 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): _LOGGER.debug("OTBR addon info: %s", addon_info) - if not self.addon_install_task: - self.addon_install_task = self.hass.async_create_task( - addon_manager.async_install_addon_waiting(), - "OTBR addon install", - ) - - if not self.addon_install_task.done(): - return self.async_show_progress( - step_id="install_otbr_addon", - progress_action="install_addon", + try: + await addon_manager.async_install_addon_waiting() + except AddonError as err: + _LOGGER.error(err) + raise AbortFlow( + "addon_install_failed", description_placeholders={ **self._get_translation_placeholders(), "addon_name": addon_manager.addon_name, }, - progress_task=self.addon_install_task, - ) + ) from err - try: - await self.addon_install_task - except AddonError as err: - _LOGGER.error(err) - self._failed_addon_name = addon_manager.addon_name - self._failed_addon_reason = "addon_install_failed" - return self.async_show_progress_done(next_step_id="addon_operation_failed") - finally: - self.addon_install_task = None - - return self.async_show_progress_done(next_step_id="finish_thread_installation") + return await self.async_step_finish_thread_installation() + @progress_step( + description_placeholders=lambda self: { + **self._get_translation_placeholders(), + "addon_name": get_otbr_addon_manager(self.hass).addon_name, + } + ) async def async_step_start_otbr_addon( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Configure OTBR to point to the SkyConnect and run the addon.""" - otbr_manager = get_otbr_addon_manager(self.hass) - - if not self.addon_start_task: - self.addon_start_task = self.hass.async_create_task( - self._configure_and_start_otbr_addon() - ) - - if not self.addon_start_task.done(): - return self.async_show_progress( - step_id="start_otbr_addon", - progress_action="start_otbr_addon", + try: + await self._configure_and_start_otbr_addon() + except AddonError as err: + _LOGGER.error(err) + raise AbortFlow( + "addon_start_failed", description_placeholders={ **self._get_translation_placeholders(), - "addon_name": otbr_manager.addon_name, + "addon_name": get_otbr_addon_manager(self.hass).addon_name, }, - progress_task=self.addon_start_task, - ) + ) from err - try: - await self.addon_start_task - except (AddonError, AbortFlow) as err: - _LOGGER.error(err) - self._failed_addon_name = otbr_manager.addon_name - self._failed_addon_reason = ( - err.reason if isinstance(err, AbortFlow) else "addon_start_failed" - ) - return self.async_show_progress_done(next_step_id="addon_operation_failed") - finally: - self.addon_start_task = None - - return self.async_show_progress_done(next_step_id="pre_confirm_otbr") + return await self.async_step_pre_confirm_otbr() async def async_step_pre_confirm_otbr( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 4402eadeda29..d9e58a8dda8b 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -5,14 +5,15 @@ from __future__ import annotations import abc import asyncio from collections import defaultdict -from collections.abc import Callable, Container, Hashable, Iterable, Mapping +from collections.abc import Callable, Container, Coroutine, Hashable, Iterable, Mapping from contextlib import suppress import copy from dataclasses import dataclass from enum import StrEnum +import functools import logging from types import MappingProxyType -from typing import Any, Generic, Required, TypedDict, TypeVar, cast +from typing import Any, Concatenate, Generic, Required, TypedDict, TypeVar, cast import voluptuous as vol @@ -150,6 +151,15 @@ class FlowResult(TypedDict, Generic[_FlowContextT, _HandlerT], total=False): url: str +class ProgressStepData[_FlowResultT](TypedDict): + """Typed data for progress step tracking.""" + + tasks: dict[str, asyncio.Task[Any]] + abort_reason: str + abort_description_placeholders: Mapping[str, str] + next_step_result: _FlowResultT | None + + def _map_error_to_schema_errors( schema_errors: dict[str, Any], error: vol.Invalid, @@ -639,6 +649,12 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]): __progress_task: asyncio.Task[Any] | None = None __no_progress_task_reported = False deprecated_show_progress = False + _progress_step_data: ProgressStepData[_FlowResultT] = { + "tasks": {}, + "abort_reason": "", + "abort_description_placeholders": MappingProxyType({}), + "next_step_result": None, + } @property def source(self) -> str | None: @@ -761,6 +777,37 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]): description_placeholders=description_placeholders, ) + async def async_step__progress_step_abort( + self, user_input: dict[str, Any] | None = None + ) -> _FlowResultT: + """Abort the flow.""" + return self.async_abort( + reason=self._progress_step_data["abort_reason"], + description_placeholders=self._progress_step_data[ + "abort_description_placeholders" + ], + ) + + async def async_step__progress_step_progress_done( + self, user_input: dict[str, Any] | None = None + ) -> _FlowResultT: + """Progress done. Return the next step. + + Used by the progress_step decorator + to allow decorated step methods + to call the next step method, to change step, + without using async_show_progress_done. + If no next step is set, abort the flow. + """ + if self._progress_step_data["next_step_result"] is None: + return self.async_abort( + reason=self._progress_step_data["abort_reason"], + description_placeholders=self._progress_step_data[ + "abort_description_placeholders" + ], + ) + return self._progress_step_data["next_step_result"] + @callback def async_external_step( self, @@ -930,3 +977,90 @@ class section: def __call__(self, value: Any) -> Any: """Validate input.""" return self.schema(value) + + +type _FuncType[_T: FlowHandler[Any, Any, Any], _R: FlowResult[Any, Any], **_P] = ( + Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]] +) + + +def progress_step[ + HandlerT: FlowHandler[Any, Any, Any], + ResultT: FlowResult[Any, Any], + **P, +]( + description_placeholders: ( + dict[str, str] | Callable[[Any], dict[str, str]] | None + ) = None, +) -> Callable[[_FuncType[HandlerT, ResultT, P]], _FuncType[HandlerT, ResultT, P]]: + """Decorator to create a progress step from an async function. + + The decorated method should be a step method + which needs to show progress. + The method should accept dict[str, Any] as user_input + and should return a FlowResult or raise AbortFlow. + The method can call self.async_update_progress(progress) + to update progress. + + Args: + description_placeholders: Static dict or callable that returns dict for progress UI placeholders. + """ + + def decorator( + func: _FuncType[HandlerT, ResultT, P], + ) -> _FuncType[HandlerT, ResultT, P]: + @functools.wraps(func) + async def wrapper( + self: FlowHandler[Any, ResultT], *args: P.args, **kwargs: P.kwargs + ) -> ResultT: + step_id = func.__name__.replace("async_step_", "") + + # Check if we have a progress task running + progress_task = self._progress_step_data["tasks"].get(step_id) + + if progress_task is None: + # First call - create and start the progress task + progress_task = self.hass.async_create_task( + func(self, *args, **kwargs), # type: ignore[arg-type] + f"Progress step {step_id}", + ) + self._progress_step_data["tasks"][step_id] = progress_task + + if not progress_task.done(): + # Handle description placeholders + placeholders = None + if description_placeholders is not None: + if callable(description_placeholders): + placeholders = description_placeholders(self) + else: + placeholders = description_placeholders + + return self.async_show_progress( + step_id=step_id, + progress_action=step_id, + progress_task=progress_task, + description_placeholders=placeholders, + ) + + # Task is done or this is a subsequent call + try: + self._progress_step_data["next_step_result"] = await progress_task + except AbortFlow as err: + self._progress_step_data["abort_reason"] = err.reason + self._progress_step_data["abort_description_placeholders"] = ( + err.description_placeholders or {} + ) + return self.async_show_progress_done( + next_step_id="_progress_step_abort" + ) + finally: + # Clean up task reference + self._progress_step_data["tasks"].pop(step_id, None) + + return self.async_show_progress_done( + next_step_id="_progress_step_progress_done" + ) + + return wrapper + + return decorator diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 8cc5fdbc89c5..296e067ae6b3 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -844,7 +844,7 @@ async def test_options_flow_zigbee_to_thread( assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "install_otbr_addon" - assert result["progress_action"] == "install_addon" + assert result["progress_action"] == "install_otbr_addon" await hass.async_block_till_done(wait_background_tasks=True) From 70077511a31c65694b74e0f42e16e5fdb13342ec Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 24 Sep 2025 11:28:55 -0400 Subject: [PATCH 118/189] Unload ZHA integration before adapter migration (#152896) --- homeassistant/components/zha/config_flow.py | 4 ++++ tests/components/zha/test_config_flow.py | 22 +++++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index cb0b26d6ac0a..4aa5c95accc4 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -444,6 +444,10 @@ class BaseZhaFlow(ConfigEntryBaseFlow): assert len(config_entries) == 1 config_entry = config_entries[0] + # Unload ZHA before connecting to the old adapter + with suppress(OperationNotAllowed): + await self.hass.config_entries.async_unload(config_entry.entry_id) + # Create a radio manager to connect to the old stick to reset it temp_radio_mgr = ZhaRadioManager() temp_radio_mgr.hass = self.hass diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 70419a4b503e..c5093dcd400c 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -5,7 +5,14 @@ from datetime import timedelta from ipaddress import ip_address import json from typing import Any -from unittest.mock import AsyncMock, MagicMock, PropertyMock, create_autospec, patch +from unittest.mock import ( + AsyncMock, + MagicMock, + PropertyMock, + call, + create_autospec, + patch, +) import uuid import pytest @@ -585,14 +592,21 @@ async def test_migration_strategy_recommended( assert result_confirm["step_id"] == "choose_migration_strategy" - with patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", - ) as mock_restore_backup: + with ( + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", + ) as mock_restore_backup, + patch( + "homeassistant.config_entries.ConfigEntries.async_unload", + return_value=True, + ) as mock_async_unload, + ): result_recommended = await hass.config_entries.flow.async_configure( result_confirm["flow_id"], user_input={"next_step_id": config_flow.MIGRATION_STRATEGY_RECOMMENDED}, ) + assert mock_async_unload.mock_calls == [call(entry.entry_id)] assert result_recommended["type"] is FlowResultType.ABORT assert result_recommended["reason"] == "reconfigure_successful" mock_restore_backup.assert_called_once() From ccf0011ac2d7bb47f0aaec69d7d38754a365707f Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 24 Sep 2025 11:31:04 -0400 Subject: [PATCH 119/189] Skip ignored discovery entries when showing migrate/setup config flow steps for ZHA and Hardware (#152895) --- .../firmware_config_flow.py | 8 ++- homeassistant/components/zha/config_flow.py | 16 +++-- .../test_config_flow.py | 64 ++++++++++++++++++- tests/components/zha/test_config_flow.py | 49 ++++++++++++++ 4 files changed, 129 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 98a2fb2f881a..895c7e726184 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -123,8 +123,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): ) -> ConfigFlowResult: """Pick Thread or Zigbee firmware.""" # Determine if ZHA or Thread are already configured to present migrate options - zha_entries = self.hass.config_entries.async_entries(ZHA_DOMAIN) - otbr_entries = self.hass.config_entries.async_entries(OTBR_DOMAIN) + zha_entries = self.hass.config_entries.async_entries( + ZHA_DOMAIN, include_ignore=False + ) + otbr_entries = self.hass.config_entries.async_entries( + OTBR_DOMAIN, include_ignore=False + ) return self.async_show_menu( step_id="pick_firmware", diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 4aa5c95accc4..5f90a3fc7d6e 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -364,7 +364,7 @@ class BaseZhaFlow(ConfigEntryBaseFlow): if user_input is not None or self._radio_mgr.radio_type in RECOMMENDED_RADIOS: # ZHA disables the single instance check and will decide at runtime if we # are migrating or setting up from scratch - if self.hass.config_entries.async_entries(DOMAIN): + if self.hass.config_entries.async_entries(DOMAIN, include_ignore=False): return await self.async_step_choose_migration_strategy() return await self.async_step_choose_setup_strategy() @@ -386,7 +386,7 @@ class BaseZhaFlow(ConfigEntryBaseFlow): # Allow onboarding for new users to just create a new network automatically if ( not onboarding.async_is_onboarded(self.hass) - and not self.hass.config_entries.async_entries(DOMAIN) + and not self.hass.config_entries.async_entries(DOMAIN, include_ignore=False) and not self._radio_mgr.backups ): return await self.async_step_setup_strategy_recommended() @@ -438,7 +438,9 @@ class BaseZhaFlow(ConfigEntryBaseFlow): """Erase the old radio's network settings before migration.""" # Like in the options flow, pull the correct settings from the config entry - config_entries = self.hass.config_entries.async_entries(DOMAIN) + config_entries = self.hass.config_entries.async_entries( + DOMAIN, include_ignore=False + ) if config_entries: assert len(config_entries) == 1 @@ -697,7 +699,9 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): self._set_confirm_only() - zha_config_entries = self.hass.config_entries.async_entries(DOMAIN) + zha_config_entries = self.hass.config_entries.async_entries( + DOMAIN, include_ignore=False + ) # Without confirmation, discovery can automatically progress into parts of the # config flow logic that interacts with hardware. @@ -866,7 +870,9 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): # ZHA is still single instance only, even though we use discovery to allow for # migrating to a new radio - zha_config_entries = self.hass.config_entries.async_entries(DOMAIN) + zha_config_entries = self.hass.config_entries.async_entries( + DOMAIN, include_ignore=False + ) data = await self._get_config_entry_data() if len(zha_config_entries) == 1: diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 296e067ae6b3..da81f2bff883 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -26,7 +26,13 @@ from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, ) -from homeassistant.config_entries import ConfigEntry, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + SOURCE_IGNORE, + SOURCE_USER, + ConfigEntry, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import HomeAssistantError @@ -1100,3 +1106,59 @@ async def test_config_flow_thread_migrate_handler(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["progress_action"] == "install_firmware" assert result["step_id"] == "install_thread_firmware" + + +@pytest.mark.parametrize( + ("zha_source", "otbr_source", "expected_menu"), + [ + ( + SOURCE_USER, + SOURCE_USER, + ["pick_firmware_zigbee_migrate", "pick_firmware_thread_migrate"], + ), + ( + SOURCE_IGNORE, + SOURCE_USER, + ["pick_firmware_zigbee", "pick_firmware_thread_migrate"], + ), + ( + SOURCE_USER, + SOURCE_IGNORE, + ["pick_firmware_zigbee_migrate", "pick_firmware_thread"], + ), + ( + SOURCE_IGNORE, + SOURCE_IGNORE, + ["pick_firmware_zigbee", "pick_firmware_thread"], + ), + ], +) +async def test_config_flow_pick_firmware_with_ignored_entries( + hass: HomeAssistant, zha_source: str, otbr_source: str, expected_menu: str +) -> None: + """Test that ignored entries are properly excluded from migration menu options.""" + zha_entry = MockConfigEntry( + domain="zha", + data={"device": {"path": "/dev/ttyUSB1"}}, + title="ZHA", + source=zha_source, + ) + zha_entry.add_to_hass(hass) + + otbr_entry = MockConfigEntry( + domain="otbr", + data={"url": "http://192.168.1.100:8081"}, + title="OTBR", + source=otbr_source, + ) + otbr_entry.add_to_hass(hass) + + # Set up the flow + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" + + assert init_result["menu_options"] == expected_menu diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index c5093dcd400c..ff4c7443fa13 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -2378,3 +2378,52 @@ async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp_writ assert mock_restore_backup.call_count == 1 assert mock_restore_backup.mock_calls[0].kwargs["overwrite_ieee"] is True + + +@patch(f"bellows.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) +async def test_migrate_setup_options_with_ignored_discovery( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that ignored discovery info is migrated to options.""" + + # Ignored ZHA + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="AAAA:AAAA_1234_test_zigbee radio", + data={ + CONF_DEVICE: { + CONF_DEVICE_PATH: "/dev/ttyUSB1", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + } + }, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + + # Set up one discovery entry + discovery_info = UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="BBBB", + vid="BBBB", + serial_number="5678", + description="zigbee radio", + manufacturer="test manufacturer", + ) + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + # Progress the discovery + confirm_result = await hass.config_entries.flow.async_configure( + discovery_result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + # We only show "setup" options, not "migrate" + assert confirm_result["step_id"] == "choose_setup_strategy" + assert confirm_result["menu_options"] == [ + "setup_strategy_recommended", + "setup_strategy_advanced", + ] From 1629ade97f1f1e554726947c5b4e20c7872f173c Mon Sep 17 00:00:00 2001 From: Ravaka Razafimanantsoa <3774520+SeraphicRav@users.noreply.github.com> Date: Thu, 25 Sep 2025 00:31:30 +0900 Subject: [PATCH 120/189] Add Smart Meter B Route integration (#123446) Co-authored-by: Joost Lekkerkerker Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .strict-typing | 1 + CODEOWNERS | 2 + .../route_b_smart_meter/__init__.py | 28 +++ .../route_b_smart_meter/config_flow.py | 116 +++++++++ .../components/route_b_smart_meter/const.py | 12 + .../route_b_smart_meter/coordinator.py | 75 ++++++ .../route_b_smart_meter/manifest.json | 17 ++ .../route_b_smart_meter/quality_scale.yaml | 82 +++++++ .../components/route_b_smart_meter/sensor.py | 109 +++++++++ .../route_b_smart_meter/strings.json | 42 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 4 + requirements_test_all.txt | 4 + .../route_b_smart_meter/__init__.py | 1 + .../route_b_smart_meter/conftest.py | 72 ++++++ .../snapshots/test_sensor.ambr | 225 ++++++++++++++++++ .../route_b_smart_meter/test_config_flow.py | 111 +++++++++ .../route_b_smart_meter/test_init.py | 19 ++ .../route_b_smart_meter/test_sensor.py | 55 +++++ 21 files changed, 992 insertions(+) create mode 100644 homeassistant/components/route_b_smart_meter/__init__.py create mode 100644 homeassistant/components/route_b_smart_meter/config_flow.py create mode 100644 homeassistant/components/route_b_smart_meter/const.py create mode 100644 homeassistant/components/route_b_smart_meter/coordinator.py create mode 100644 homeassistant/components/route_b_smart_meter/manifest.json create mode 100644 homeassistant/components/route_b_smart_meter/quality_scale.yaml create mode 100644 homeassistant/components/route_b_smart_meter/sensor.py create mode 100644 homeassistant/components/route_b_smart_meter/strings.json create mode 100644 tests/components/route_b_smart_meter/__init__.py create mode 100644 tests/components/route_b_smart_meter/conftest.py create mode 100644 tests/components/route_b_smart_meter/snapshots/test_sensor.ambr create mode 100644 tests/components/route_b_smart_meter/test_config_flow.py create mode 100644 tests/components/route_b_smart_meter/test_init.py create mode 100644 tests/components/route_b_smart_meter/test_sensor.py diff --git a/.strict-typing b/.strict-typing index a4152b78ca0c..d483d04f7026 100644 --- a/.strict-typing +++ b/.strict-typing @@ -443,6 +443,7 @@ homeassistant.components.rituals_perfume_genie.* homeassistant.components.roborock.* homeassistant.components.roku.* homeassistant.components.romy.* +homeassistant.components.route_b_smart_meter.* homeassistant.components.rpi_power.* homeassistant.components.rss_feed_template.* homeassistant.components.russound_rio.* diff --git a/CODEOWNERS b/CODEOWNERS index 59b72f3550b3..c68c96f4f246 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1332,6 +1332,8 @@ build.json @home-assistant/supervisor /tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous /homeassistant/components/roon/ @pavoni /tests/components/roon/ @pavoni +/homeassistant/components/route_b_smart_meter/ @SeraphicRav +/tests/components/route_b_smart_meter/ @SeraphicRav /homeassistant/components/rpi_power/ @shenxn @swetoast /tests/components/rpi_power/ @shenxn @swetoast /homeassistant/components/rss_feed_template/ @home-assistant/core diff --git a/homeassistant/components/route_b_smart_meter/__init__.py b/homeassistant/components/route_b_smart_meter/__init__.py new file mode 100644 index 000000000000..5e8a941c73e9 --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/__init__.py @@ -0,0 +1,28 @@ +"""The Smart Meter B Route integration.""" + +import logging + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import BRouteConfigEntry, BRouteUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: BRouteConfigEntry) -> bool: + """Set up Smart Meter B Route from a config entry.""" + + coordinator = BRouteUpdateCoordinator(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: BRouteConfigEntry) -> bool: + """Unload a config entry.""" + await hass.async_add_executor_job(entry.runtime_data.api.close) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/route_b_smart_meter/config_flow.py b/homeassistant/components/route_b_smart_meter/config_flow.py new file mode 100644 index 000000000000..1cbeeab4c4e6 --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/config_flow.py @@ -0,0 +1,116 @@ +"""Config flow for Smart Meter B Route integration.""" + +import logging +from typing import Any + +from momonga import Momonga, MomongaSkJoinFailure, MomongaSkScanFailure +from serial.tools.list_ports import comports +from serial.tools.list_ports_common import ListPortInfo +import voluptuous as vol + +from homeassistant.components.usb import get_serial_by_id, human_readable_device_name +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_PASSWORD +from homeassistant.core import callback +from homeassistant.helpers.service_info.usb import UsbServiceInfo + +from .const import DOMAIN, ENTRY_TITLE + +_LOGGER = logging.getLogger(__name__) + + +def _validate_input(device: str, id: str, password: str) -> None: + """Validate the user input allows us to connect.""" + with Momonga(dev=device, rbid=id, pwd=password): + pass + + +def _human_readable_device_name(port: UsbServiceInfo | ListPortInfo) -> str: + return human_readable_device_name( + port.device, + port.serial_number, + port.manufacturer, + port.description, + str(port.vid) if port.vid else None, + str(port.pid) if port.pid else None, + ) + + +class BRouteConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Smart Meter B Route.""" + + VERSION = 1 + + device: UsbServiceInfo | None = None + + @callback + def _get_discovered_device_id_and_name( + self, device_options: dict[str, ListPortInfo] + ) -> tuple[str | None, str | None]: + discovered_device_id = ( + get_serial_by_id(self.device.device) if self.device else None + ) + discovered_device = ( + device_options.get(discovered_device_id) if discovered_device_id else None + ) + discovered_device_name = ( + _human_readable_device_name(discovered_device) + if discovered_device + else None + ) + return discovered_device_id, discovered_device_name + + async def _get_usb_devices(self) -> dict[str, ListPortInfo]: + """Return a list of available USB devices.""" + devices = await self.hass.async_add_executor_job(comports) + return {get_serial_by_id(port.device): port for port in devices} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + device_options = await self._get_usb_devices() + if user_input is not None: + try: + await self.hass.async_add_executor_job( + _validate_input, + user_input[CONF_DEVICE], + user_input[CONF_ID], + user_input[CONF_PASSWORD], + ) + except MomongaSkScanFailure: + errors["base"] = "cannot_connect" + except MomongaSkJoinFailure: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id( + user_input[CONF_ID], raise_on_progress=False + ) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=ENTRY_TITLE, data=user_input) + + discovered_device_id, discovered_device_name = ( + self._get_discovered_device_id_and_name(device_options) + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_DEVICE, default=discovered_device_id): vol.In( + {discovered_device_id: discovered_device_name} + if discovered_device_id and discovered_device_name + else { + name: _human_readable_device_name(device) + for name, device in device_options.items() + } + ), + vol.Required(CONF_ID): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/route_b_smart_meter/const.py b/homeassistant/components/route_b_smart_meter/const.py new file mode 100644 index 000000000000..ecd3fc48bfcd --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/const.py @@ -0,0 +1,12 @@ +"""Constants for the Smart Meter B Route integration.""" + +from datetime import timedelta + +DOMAIN = "route_b_smart_meter" +ENTRY_TITLE = "Route B Smart Meter" +DEFAULT_SCAN_INTERVAL = timedelta(seconds=300) + +ATTR_API_INSTANTANEOUS_POWER = "instantaneous_power" +ATTR_API_TOTAL_CONSUMPTION = "total_consumption" +ATTR_API_INSTANTANEOUS_CURRENT_T_PHASE = "instantaneous_current_t_phase" +ATTR_API_INSTANTANEOUS_CURRENT_R_PHASE = "instantaneous_current_r_phase" diff --git a/homeassistant/components/route_b_smart_meter/coordinator.py b/homeassistant/components/route_b_smart_meter/coordinator.py new file mode 100644 index 000000000000..7cfa2810b5b0 --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/coordinator.py @@ -0,0 +1,75 @@ +"""DataUpdateCoordinator for the Smart Meter B-route integration.""" + +from dataclasses import dataclass +import logging + +from momonga import Momonga, MomongaError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class BRouteData: + """Class for data of the B Route.""" + + instantaneous_current_r_phase: float + instantaneous_current_t_phase: float + instantaneous_power: float + total_consumption: float + + +type BRouteConfigEntry = ConfigEntry[BRouteUpdateCoordinator] + + +class BRouteUpdateCoordinator(DataUpdateCoordinator[BRouteData]): + """The B Route update coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + entry: BRouteConfigEntry, + ) -> None: + """Initialize.""" + + self.device = entry.data[CONF_DEVICE] + self.bid = entry.data[CONF_ID] + password = entry.data[CONF_PASSWORD] + + self.api = Momonga(dev=self.device, rbid=self.bid, pwd=password) + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + config_entry=entry, + update_interval=DEFAULT_SCAN_INTERVAL, + ) + + async def _async_setup(self) -> None: + await self.hass.async_add_executor_job( + self.api.open, + ) + + def _get_data(self) -> BRouteData: + """Get the data from API.""" + current = self.api.get_instantaneous_current() + return BRouteData( + instantaneous_current_r_phase=current["r phase current"], + instantaneous_current_t_phase=current["t phase current"], + instantaneous_power=self.api.get_instantaneous_power(), + total_consumption=self.api.get_measured_cumulative_energy(), + ) + + async def _async_update_data(self) -> BRouteData: + """Update data.""" + try: + return await self.hass.async_add_executor_job(self._get_data) + except MomongaError as error: + raise UpdateFailed(error) from error diff --git a/homeassistant/components/route_b_smart_meter/manifest.json b/homeassistant/components/route_b_smart_meter/manifest.json new file mode 100644 index 000000000000..d1189d0a5420 --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "route_b_smart_meter", + "name": "Smart Meter B Route", + "codeowners": ["@SeraphicRav"], + "config_flow": true, + "dependencies": ["usb"], + "documentation": "https://www.home-assistant.io/integrations/route_b_smart_meter", + "integration_type": "device", + "iot_class": "local_polling", + "loggers": [ + "momonga.momonga", + "momonga.momonga_session_manager", + "momonga.sk_wrapper_logger" + ], + "quality_scale": "bronze", + "requirements": ["pyserial==3.5", "momonga==0.1.5"] +} diff --git a/homeassistant/components/route_b_smart_meter/quality_scale.yaml b/homeassistant/components/route_b_smart_meter/quality_scale.yaml new file mode 100644 index 000000000000..f6123b6e4c91 --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration does not provide any additional actions. + appropriate-polling: + status: done + brands: + status: exempt + comment: | + The integration is not specific to a single brand, it does not have a logo. + common-modules: done + config-flow: done + config-flow-test-coverage: 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: + status: exempt + comment: | + The integration does not use 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 additional actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: + status: exempt + comment: | + The manufacturer does not use unique identifiers for devices. + 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: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: + status: exempt + comment: | + The integration does not use HTTP. + strict-typing: todo diff --git a/homeassistant/components/route_b_smart_meter/sensor.py b/homeassistant/components/route_b_smart_meter/sensor.py new file mode 100644 index 000000000000..c8034528f5ac --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/sensor.py @@ -0,0 +1,109 @@ +"""Smart Meter B Route.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfElectricCurrent, UnitOfEnergy, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import BRouteConfigEntry +from .const import ( + ATTR_API_INSTANTANEOUS_CURRENT_R_PHASE, + ATTR_API_INSTANTANEOUS_CURRENT_T_PHASE, + ATTR_API_INSTANTANEOUS_POWER, + ATTR_API_TOTAL_CONSUMPTION, + DOMAIN, +) +from .coordinator import BRouteData, BRouteUpdateCoordinator + + +@dataclass(frozen=True, kw_only=True) +class SensorEntityDescriptionWithValueAccessor(SensorEntityDescription): + """Sensor entity description with data accessor.""" + + value_accessor: Callable[[BRouteData], StateType] + + +SENSOR_DESCRIPTIONS = ( + SensorEntityDescriptionWithValueAccessor( + key=ATTR_API_INSTANTANEOUS_CURRENT_R_PHASE, + translation_key=ATTR_API_INSTANTANEOUS_CURRENT_R_PHASE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_accessor=lambda data: data.instantaneous_current_r_phase, + ), + SensorEntityDescriptionWithValueAccessor( + key=ATTR_API_INSTANTANEOUS_CURRENT_T_PHASE, + translation_key=ATTR_API_INSTANTANEOUS_CURRENT_T_PHASE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_accessor=lambda data: data.instantaneous_current_t_phase, + ), + SensorEntityDescriptionWithValueAccessor( + key=ATTR_API_INSTANTANEOUS_POWER, + translation_key=ATTR_API_INSTANTANEOUS_POWER, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_accessor=lambda data: data.instantaneous_power, + ), + SensorEntityDescriptionWithValueAccessor( + key=ATTR_API_TOTAL_CONSUMPTION, + translation_key=ATTR_API_TOTAL_CONSUMPTION, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_accessor=lambda data: data.total_consumption, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BRouteConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Smart Meter B-route entry.""" + coordinator = entry.runtime_data + + async_add_entities( + SmartMeterBRouteSensor(coordinator, description) + for description in SENSOR_DESCRIPTIONS + ) + + +class SmartMeterBRouteSensor(CoordinatorEntity[BRouteUpdateCoordinator], SensorEntity): + """Representation of a Smart Meter B-route sensor entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: BRouteUpdateCoordinator, + description: SensorEntityDescriptionWithValueAccessor, + ) -> None: + """Initialize Smart Meter B-route sensor entity.""" + super().__init__(coordinator) + self.entity_description: SensorEntityDescriptionWithValueAccessor = description + self._attr_unique_id = f"{coordinator.bid}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.bid)}, + name=f"Route B Smart Meter {coordinator.bid}", + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_accessor(self.coordinator.data) diff --git a/homeassistant/components/route_b_smart_meter/strings.json b/homeassistant/components/route_b_smart_meter/strings.json new file mode 100644 index 000000000000..382ff6edaa0a --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/strings.json @@ -0,0 +1,42 @@ +{ + "config": { + "step": { + "user": { + "data_description": { + "device": "[%key:common::config_flow::data::device%]", + "id": "B Route ID", + "password": "[%key:common::config_flow::data::password%]" + }, + "data": { + "device": "[%key:common::config_flow::data::device%]", + "id": "B Route ID", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "instantaneous_power": { + "name": "Instantaneous power" + }, + "total_consumption": { + "name": "Total consumption" + }, + "instantaneous_current_t_phase": { + "name": "Instantaneous current T phase" + }, + "instantaneous_current_r_phase": { + "name": "Instantaneous current R phase" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5cdff2219574..711c9f793e2e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -552,6 +552,7 @@ FLOWS = { "romy", "roomba", "roon", + "route_b_smart_meter", "rova", "rpi_power", "ruckus_unleashed", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index fb4d3a199215..d188c31d81fa 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5637,6 +5637,12 @@ } } }, + "route_b_smart_meter": { + "name": "Smart Meter B Route", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "rova": { "name": "ROVA", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 4bfe2a10063a..dcf71efe8982 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4186,6 +4186,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.route_b_smart_meter.*] +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.rpi_power.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 5500f3385a32..d5a89bc7e6b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1466,6 +1466,9 @@ moat-ble==0.1.1 # homeassistant.components.moehlenhoff_alpha2 moehlenhoff-alpha2==1.4.0 +# homeassistant.components.route_b_smart_meter +momonga==0.1.5 + # homeassistant.components.monzo monzopy==1.5.1 @@ -2345,6 +2348,7 @@ pyserial-asyncio-fast==0.16 # homeassistant.components.acer_projector # homeassistant.components.crownstone +# homeassistant.components.route_b_smart_meter # homeassistant.components.usb # homeassistant.components.zwave_js pyserial==3.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f0bf24d867e..567484e9f225 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1258,6 +1258,9 @@ moat-ble==0.1.1 # homeassistant.components.moehlenhoff_alpha2 moehlenhoff-alpha2==1.4.0 +# homeassistant.components.route_b_smart_meter +momonga==0.1.5 + # homeassistant.components.monzo monzopy==1.5.1 @@ -1957,6 +1960,7 @@ pysensibo==1.2.1 # homeassistant.components.acer_projector # homeassistant.components.crownstone +# homeassistant.components.route_b_smart_meter # homeassistant.components.usb # homeassistant.components.zwave_js pyserial==3.5 diff --git a/tests/components/route_b_smart_meter/__init__.py b/tests/components/route_b_smart_meter/__init__.py new file mode 100644 index 000000000000..7b998b1f4bd9 --- /dev/null +++ b/tests/components/route_b_smart_meter/__init__.py @@ -0,0 +1 @@ +"""Tests for the Smart Meter B-route integration.""" diff --git a/tests/components/route_b_smart_meter/conftest.py b/tests/components/route_b_smart_meter/conftest.py new file mode 100644 index 000000000000..f0a84c252a0c --- /dev/null +++ b/tests/components/route_b_smart_meter/conftest.py @@ -0,0 +1,72 @@ +"""Common fixtures for the Smart Meter B-route tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from homeassistant.components.route_b_smart_meter.const import DOMAIN +from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.route_b_smart_meter.async_setup_entry", + return_value=True, + ) as mock: + yield mock + + +@pytest.fixture +def mock_momonga(exception=None) -> Generator[Mock]: + """Mock for Momonga class.""" + + with ( + patch( + "homeassistant.components.route_b_smart_meter.coordinator.Momonga", + ) as mock_momonga, + patch( + "homeassistant.components.route_b_smart_meter.config_flow.Momonga", + new=mock_momonga, + ), + ): + client = mock_momonga.return_value + client.__enter__.return_value = client + client.__exit__.return_value = None + client.get_instantaneous_current.return_value = { + "r phase current": 1, + "t phase current": 2, + } + client.get_instantaneous_power.return_value = 3 + client.get_measured_cumulative_energy.return_value = 4 + yield mock_momonga + + +@pytest.fixture +def user_input() -> dict[str, str]: + """Return test user input data.""" + return { + CONF_DEVICE: "/dev/ttyUSB42", + CONF_ID: "01234567890123456789012345F789", + CONF_PASSWORD: "B_ROUTE_PASSWORD", + } + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, user_input: dict[str, str] +) -> MockConfigEntry: + """Create a mock config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=user_input, + entry_id="01234567890123456789012345F789", + unique_id="123456", + ) + entry.add_to_hass(hass) + return entry diff --git a/tests/components/route_b_smart_meter/snapshots/test_sensor.ambr b/tests/components/route_b_smart_meter/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..552e46aa6876 --- /dev/null +++ b/tests/components/route_b_smart_meter/snapshots/test_sensor.ambr @@ -0,0 +1,225 @@ +# serializer version: 1 +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_r_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_r_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Instantaneous current R phase', + 'platform': 'route_b_smart_meter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'instantaneous_current_r_phase', + 'unique_id': '01234567890123456789012345F789_instantaneous_current_r_phase', + 'unit_of_measurement': , + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_r_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Route B Smart Meter 01234567890123456789012345F789 Instantaneous current R phase', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_r_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_t_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_t_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Instantaneous current T phase', + 'platform': 'route_b_smart_meter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'instantaneous_current_t_phase', + 'unique_id': '01234567890123456789012345F789_instantaneous_current_t_phase', + 'unit_of_measurement': , + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_t_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Route B Smart Meter 01234567890123456789012345F789 Instantaneous current T phase', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_t_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_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.route_b_smart_meter_01234567890123456789012345f789_instantaneous_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': 'Instantaneous power', + 'platform': 'route_b_smart_meter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'instantaneous_power', + 'unique_id': '01234567890123456789012345F789_instantaneous_power', + 'unit_of_measurement': , + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Route B Smart Meter 01234567890123456789012345F789 Instantaneous power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_total_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.route_b_smart_meter_01234567890123456789012345f789_total_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': 'Total consumption', + 'platform': 'route_b_smart_meter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_consumption', + 'unique_id': '01234567890123456789012345F789_total_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_total_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Route B Smart Meter 01234567890123456789012345F789 Total consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.route_b_smart_meter_01234567890123456789012345f789_total_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- diff --git a/tests/components/route_b_smart_meter/test_config_flow.py b/tests/components/route_b_smart_meter/test_config_flow.py new file mode 100644 index 000000000000..d7dc84a99992 --- /dev/null +++ b/tests/components/route_b_smart_meter/test_config_flow.py @@ -0,0 +1,111 @@ +"""Test the Smart Meter B-route config flow.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +from momonga import MomongaSkJoinFailure, MomongaSkScanFailure +import pytest +from serial.tools.list_ports_linux import SysFS + +from homeassistant.components.route_b_smart_meter.const import DOMAIN, ENTRY_TITLE +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +@pytest.fixture +def mock_comports() -> Generator[AsyncMock]: + """Override comports.""" + device = SysFS("/dev/ttyUSB42") + device.vid = 0x1234 + device.pid = 0x5678 + device.serial_number = "123456" + device.manufacturer = "Test" + device.description = "Test Device" + + with patch( + "homeassistant.components.route_b_smart_meter.config_flow.comports", + return_value=[SysFS("/dev/ttyUSB41"), device], + ) as mock: + yield mock + + +async def test_step_user_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_comports: AsyncMock, + mock_momonga: Mock, + user_input: dict[str, str], +) -> 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"] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ENTRY_TITLE + assert result["data"] == user_input + assert result["result"].unique_id == user_input[CONF_ID] + mock_setup_entry.assert_called_once() + mock_comports.assert_called() + mock_momonga.assert_called_once_with( + dev=user_input[CONF_DEVICE], + rbid=user_input[CONF_ID], + pwd=user_input[CONF_PASSWORD], + ) + + +@pytest.mark.parametrize( + ("error", "message"), + [ + (MomongaSkJoinFailure, "invalid_auth"), + (MomongaSkScanFailure, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_step_user_form_errors( + hass: HomeAssistant, + error: Exception, + message: str, + mock_setup_entry: AsyncMock, + mock_comports: AsyncMock, + mock_momonga: AsyncMock, + user_input: dict[str, str], +) -> None: + """Test we handle error.""" + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + mock_momonga.side_effect = error + result_configure = await hass.config_entries.flow.async_configure( + result_init["flow_id"], + user_input, + ) + + assert result_configure["type"] is FlowResultType.FORM + assert result_configure["errors"] == {"base": message} + await hass.async_block_till_done() + mock_comports.assert_called() + mock_momonga.assert_called_once_with( + dev=user_input[CONF_DEVICE], + rbid=user_input[CONF_ID], + pwd=user_input[CONF_PASSWORD], + ) + + mock_momonga.side_effect = None + result = await hass.config_entries.flow.async_configure( + result_configure["flow_id"], + user_input, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ENTRY_TITLE + assert result["data"] == user_input diff --git a/tests/components/route_b_smart_meter/test_init.py b/tests/components/route_b_smart_meter/test_init.py new file mode 100644 index 000000000000..644fda848861 --- /dev/null +++ b/tests/components/route_b_smart_meter/test_init.py @@ -0,0 +1,19 @@ +"""Tests for the Smart Meter B Route integration init.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_async_setup_entry_success( + hass: HomeAssistant, mock_momonga, mock_config_entry: MockConfigEntry +) -> None: + """Test successful setup of entry.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/route_b_smart_meter/test_sensor.py b/tests/components/route_b_smart_meter/test_sensor.py new file mode 100644 index 000000000000..63d9cac04499 --- /dev/null +++ b/tests/components/route_b_smart_meter/test_sensor.py @@ -0,0 +1,55 @@ +"""Tests for the Smart Meter B-Route sensor.""" + +from unittest.mock import Mock + +from freezegun.api import FrozenDateTimeFactory +from momonga import MomongaError +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.route_b_smart_meter.const import DEFAULT_SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_route_b_smart_meter_sensor_update( + hass: HomeAssistant, + mock_momonga: Mock, + freezer: FrozenDateTimeFactory, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the BRouteUpdateCoordinator successful behavior.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_route_b_smart_meter_sensor_no_update( + hass: HomeAssistant, + mock_momonga: Mock, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the BRouteUpdateCoordinator when failing.""" + + entity_id = "sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_r_phase" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity = hass.states.get(entity_id) + assert entity.state == "1" + + mock_momonga.return_value.get_instantaneous_current.side_effect = MomongaError + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + entity = hass.states.get(entity_id) + assert entity.state is STATE_UNAVAILABLE From 5cb186980a0c302ee0c964d79df90d811e0cbc21 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 24 Sep 2025 11:35:23 -0400 Subject: [PATCH 121/189] Mark MQTT as service (#152899) --- homeassistant/components/mqtt/manifest.json | 1 + homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/manifest.json b/homeassistant/components/mqtt/manifest.json index 1cd6ae3e47c0..754d07c10fe5 100644 --- a/homeassistant/components/mqtt/manifest.json +++ b/homeassistant/components/mqtt/manifest.json @@ -6,6 +6,7 @@ "config_flow": true, "dependencies": ["file_upload", "http"], "documentation": "https://www.home-assistant.io/integrations/mqtt", + "integration_type": "service", "iot_class": "local_push", "quality_scale": "platinum", "requirements": ["paho-mqtt==2.1.0"], diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d188c31d81fa..8ab7e165dcf4 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4168,7 +4168,7 @@ "name": "Manual MQTT Alarm Control Panel" }, "mqtt": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "local_push", "name": "MQTT" From 9a801424c7f74c59eca13ad95b12d393aeadc3d2 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 24 Sep 2025 17:38:40 +0200 Subject: [PATCH 122/189] Fix deleting message filters in ntfy integration (#152783) --- homeassistant/components/ntfy/config_flow.py | 7 ++++++- tests/components/ntfy/test_config_flow.py | 5 ++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ntfy/config_flow.py b/homeassistant/components/ntfy/config_flow.py index 0a0ea05fcd6e..5f168c977c42 100644 --- a/homeassistant/components/ntfy/config_flow.py +++ b/homeassistant/components/ntfy/config_flow.py @@ -473,7 +473,12 @@ class TopicSubentryFlowHandler(ConfigSubentryFlow): return self.async_update_and_abort( entry=entry, subentry=subentry, - data_updates=user_input, + data_updates={ + CONF_PRIORITY: user_input.get(CONF_PRIORITY), + CONF_TAGS: user_input.get(CONF_TAGS), + CONF_TITLE: user_input.get(CONF_TITLE), + CONF_MESSAGE: user_input.get(CONF_MESSAGE), + }, ) return self.async_show_form( diff --git a/tests/components/ntfy/test_config_flow.py b/tests/components/ntfy/test_config_flow.py index 9e83858e7938..00118d283361 100644 --- a/tests/components/ntfy/test_config_flow.py +++ b/tests/components/ntfy/test_config_flow.py @@ -786,7 +786,7 @@ async def test_topic_reconfigure_flow(hass: HomeAssistant) -> None: CONF_PRIORITY: ["1"], CONF_TAGS: ["owl", "-1"], CONF_TITLE: "", - CONF_MESSAGE: "", + CONF_MESSAGE: "triggered", }, subentry_id="subentry_id", subentry_type="topic", @@ -810,7 +810,6 @@ async def test_topic_reconfigure_flow(hass: HomeAssistant) -> None: CONF_PRIORITY: ["5"], CONF_TAGS: ["octopus", "+1"], CONF_TITLE: "title", - CONF_MESSAGE: "triggered", }, ) @@ -824,7 +823,7 @@ async def test_topic_reconfigure_flow(hass: HomeAssistant) -> None: CONF_PRIORITY: ["5"], CONF_TAGS: ["octopus", "+1"], CONF_TITLE: "title", - CONF_MESSAGE: "triggered", + CONF_MESSAGE: None, }, subentry_id="subentry_id", subentry_type="topic", From e79a434d9b84cd35e987a9a0d64fbead98e1e5a2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 24 Sep 2025 17:39:46 +0200 Subject: [PATCH 123/189] Use DeviceCategory in Tuya remaining platforms (#152890) --- homeassistant/components/tuya/const.py | 62 ++++++- homeassistant/components/tuya/cover.py | 21 +-- homeassistant/components/tuya/sensor.py | 234 +++++++----------------- homeassistant/components/tuya/switch.py | 200 ++++++-------------- 4 files changed, 175 insertions(+), 342 deletions(-) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 158494946028..b94530e432b0 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -157,13 +157,16 @@ class DeviceCategory(StrEnum): https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld """ CWYSJ = "cwysj" - """Pet fountain""" + """Pet fountain + + https://developer.tuya.com/en/docs/iot/categorycwysj?id=Kaiuz2dfro0nd + """ CZ = "cz" """Socket""" DBL = "dbl" """Electric fireplace - https://developer.tuya.com/en/docs/iot/f?id=Kacpeobojffop + https://developer.tuya.com/en/docs/iot/electric-fireplace?id=Kaiuz2hz4iyp6 """ DC = "dc" """String lights @@ -188,7 +191,10 @@ class DeviceCategory(StrEnum): https://developer.tuya.com/en/docs/iot/categorydj?id=Kaiuyzy3eheyy """ DLQ = "dlq" - """Circuit breaker""" + """Circuit breaker + + https://developer.tuya.com/en/docs/iot/dlq?id=Kb0kidk9enyh8 + """ DR = "dr" """Electric blanket @@ -212,7 +218,10 @@ class DeviceCategory(StrEnum): https://developer.tuya.com/en/docs/iot/ambient-light?id=Kaiuz06amhe6g """ GGQ = "ggq" - """Irrigator""" + """Irrigator + + https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k + """ GYD = "gyd" """Motion sensor light @@ -307,7 +316,10 @@ class DeviceCategory(StrEnum): MS_CATEGORY = "ms_category" """Lock accessories""" MSP = "msp" - """Cat toilet""" + """Cat toilet + + https://developer.tuya.com/en/docs/iot/s?id=Kakg3srr4ora7 + """ MZJ = "mzj" """Sous vide cooker @@ -428,7 +440,10 @@ class DeviceCategory(StrEnum): XFJ = "xfj" """Ventilation system""" XXJ = "xxj" - """Diffuser""" + """Diffuser + + https://developer.tuya.com/en/docs/iot/categoryxxj?id=Kaiuz1f9mo6bl + """ XY = "xy" """Washing machine""" YB = "yb" @@ -456,7 +471,10 @@ class DeviceCategory(StrEnum): https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno """ ZNDB = "zndb" - """Smart electricity meter""" + """Smart electricity meter + + https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7 + """ ZNFH = "znfh" """Bento box""" ZNSB = "znsb" @@ -465,6 +483,8 @@ class DeviceCategory(StrEnum): """Smart pill box""" # Undocumented + AQCZ = "aqcz" + """Single Phase power meter (undocumented)""" BZYD = "bzyd" """White noise machine (undocumented)""" CWJWQ = "cwjwq" @@ -486,6 +506,11 @@ class DeviceCategory(StrEnum): """ FSKG = "fskg" """Fan wall switch (undocumented)""" + HJJCY = "hjjcy" + """Air Quality Monitor + + https://developer.tuya.com/en/docs/iot/hjjcy?id=Kbeoad8y1nnlv + """ HXD = "hxd" """Wake Up Light II (undocumented)""" JDCLJQR = "jdcljqr" @@ -502,6 +527,8 @@ class DeviceCategory(StrEnum): Found as VECINO RGBW as provided by diagnostics """ + QCCDZ = "qccdz" + """AC charging (undocumented)""" QJDCZ = "qjdcz" """ Unknown product with light capabilities @@ -516,6 +543,8 @@ class DeviceCategory(StrEnum): """Smart Water Timer (undocumented)""" SJZ = "sjz" """Electric desk (undocumented)""" + SZJCY = "szjcy" + """Water tester (undocumented)""" SZJQR = "szjqr" """Fingerbot (undocumented)""" SWTZ = "swtz" @@ -531,8 +560,19 @@ class DeviceCategory(StrEnum): https://developer.tuya.com/en/docs/iot/wg?id=Kbcdadk79ejok """ + WKCZ = "wkcz" + """Two-way temperature and humidity switch (undocumented) + + "MOES Temperature and Humidity Smart Switch Module MS-103" + """ WKF = "wkf" """Thermostatic Radiator Valve (undocumented)""" + WNYKQ = "wnykq" + """Smart WiFi IR Remote (undocumented) + + eMylo Smart WiFi IR Remote + Air Conditioner Mate (Smart IR Socket) + """ WXKG = "wxkg" # Documented, but not in official list """Wireless Switch @@ -545,8 +585,14 @@ class DeviceCategory(StrEnum): """ YWCGQ = "ywcgq" """Tank Level Sensor (undocumented)""" + ZNNBQ = "znnbq" + """VESKA-micro inverter (undocumented)""" + ZWJCY = "zwjcy" + """Soil sensor - plant monitor (undocumented)""" + ZNJXS = "znjxs" + """Hejhome whitelabel Fingerbot (undocumented)""" ZNRB = "znrb" - """Pool HeatPump""" + """Pool HeatPump (undocumented)""" class DPCode(StrEnum): diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 3464b535c474..16fa9f294ea6 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -20,7 +20,7 @@ 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, DPType +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity from .models import EnumTypeData, IntegerTypeData from .util import get_dpcode @@ -40,10 +40,8 @@ class TuyaCoverEntityDescription(CoverEntityDescription): motor_reverse_mode: DPCode | None = None -COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { - # Garage Door Opener - # https://developer.tuya.com/en/docs/iot/categoryckmkzq?id=Kaiuz0ipcboee - "ckmkzq": ( +COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = { + DeviceCategory.CKMKZQ: ( TuyaCoverEntityDescription( key=DPCode.SWITCH_1, translation_key="indexed_door", @@ -69,10 +67,7 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { device_class=CoverDeviceClass.GARAGE, ), ), - # Curtain - # Note: Multiple curtains isn't documented - # https://developer.tuya.com/en/docs/iot/categorycl?id=Kaiuz1hnpo7df - "cl": ( + DeviceCategory.CL: ( TuyaCoverEntityDescription( key=DPCode.CONTROL, translation_key="curtain", @@ -117,9 +112,7 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { device_class=CoverDeviceClass.BLIND, ), ), - # Curtain Switch - # https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39 - "clkg": ( + DeviceCategory.CLKG: ( TuyaCoverEntityDescription( key=DPCode.CONTROL, translation_key="curtain", @@ -138,9 +131,7 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { device_class=CoverDeviceClass.CURTAIN, ), ), - # Curtain Robot - # Note: Not documented - "jdcljqr": ( + DeviceCategory.JDCLJQR: ( TuyaCoverEntityDescription( key=DPCode.CONTROL, translation_key="curtain", diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 3851287ce466..f00b034c8a24 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -37,6 +37,7 @@ from .const import ( DOMAIN, LOGGER, TUYA_DISCOVERY_NEW, + DeviceCategory, DPCode, DPType, UnitOfMeasurement, @@ -115,11 +116,8 @@ BATTERY_SENSORS: tuple[TuyaSensorEntityDescription, ...] = ( # All descriptions can be found here. Mostly the Integer data types in the # default status set of each category (that don't have a set instruction) # end up being a sensor. -# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { - # Single Phase power meter - # Note: Undocumented - "aqcz": ( +SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = { + DeviceCategory.AQCZ: ( TuyaSensorEntityDescription( key=DPCode.CUR_CURRENT, translation_key="current", @@ -144,9 +142,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { entity_registry_enabled_default=False, ), ), - # Smart Kettle - # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 - "bh": ( + DeviceCategory.BH: ( TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, translation_key="current_temperature", @@ -164,18 +160,14 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="status", ), ), - # Curtain - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qy7wkre - "cl": ( + DeviceCategory.CL: ( TuyaSensorEntityDescription( key=DPCode.TIME_TOTAL, translation_key="last_operation_duration", entity_category=EntityCategory.DIAGNOSTIC, ), ), - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( + DeviceCategory.CO2BJ: ( TuyaSensorEntityDescription( key=DPCode.HUMIDITY_VALUE, translation_key="humidity", @@ -221,9 +213,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # CO Detector - # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v - "cobj": ( + DeviceCategory.COBJ: ( TuyaSensorEntityDescription( key=DPCode.CO_VALUE, translation_key="carbon_monoxide", @@ -233,9 +223,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # Dehumidifier - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r6jke8e - "cs": ( + DeviceCategory.CS: ( TuyaSensorEntityDescription( key=DPCode.TEMP_INDOOR, translation_key="temperature", @@ -249,27 +237,21 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), - # Smart Odor Eliminator-Pro - # Undocumented, see https://github.com/orgs/home-assistant/discussions/79 - "cwjwq": ( + DeviceCategory.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": ( + DeviceCategory.CWWSQ: ( TuyaSensorEntityDescription( key=DPCode.FEED_REPORT, translation_key="last_amount", state_class=SensorStateClass.MEASUREMENT, ), ), - # Pet Fountain - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r0as4ln - "cwysj": ( + DeviceCategory.CWYSJ: ( TuyaSensorEntityDescription( key=DPCode.UV_RUNTIME, translation_key="uv_runtime", @@ -300,9 +282,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { key=DPCode.WATER_LEVEL, translation_key="water_level_state" ), ), - # Multi-functional Sensor - # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 - "dgnbj": ( + DeviceCategory.DGNBJ: ( TuyaSensorEntityDescription( key=DPCode.GAS_SENSOR_VALUE, translation_key="gas", @@ -376,9 +356,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # Circuit Breaker - # https://developer.tuya.com/en/docs/iot/dlq?id=Kb0kidk9enyh8 - "dlq": ( + DeviceCategory.DLQ: ( TuyaSensorEntityDescription( key=DPCode.TOTAL_FORWARD_ENERGY, translation_key="total_energy", @@ -515,9 +493,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { entity_registry_enabled_default=False, ), ), - # Fan - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48quojr54 - "fs": ( + DeviceCategory.FS: ( TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, translation_key="temperature", @@ -525,12 +501,8 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), - # Irrigator - # https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k - "ggq": BATTERY_SENSORS, - # Air Quality Monitor - # https://developer.tuya.com/en/docs/iot/hjjcy?id=Kbeoad8y1nnlv - "hjjcy": ( + DeviceCategory.GGQ: BATTERY_SENSORS, + DeviceCategory.HJJCY: ( TuyaSensorEntityDescription( key=DPCode.AIR_QUALITY_INDEX, translation_key="air_quality_index", @@ -581,9 +553,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # Formaldehyde Detector - # Note: Not documented - "jqbj": ( + DeviceCategory.JQBJ: ( TuyaSensorEntityDescription( key=DPCode.CO2_VALUE, translation_key="carbon_dioxide", @@ -623,9 +593,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # Humidifier - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qwjz0i3 - "jsq": ( + DeviceCategory.JSQ: ( TuyaSensorEntityDescription( key=DPCode.HUMIDITY_CURRENT, translation_key="humidity", @@ -650,9 +618,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { entity_category=EntityCategory.DIAGNOSTIC, ), ), - # Methane Detector - # https://developer.tuya.com/en/docs/iot/categoryjwbj?id=Kaiuz40u98lkm - "jwbj": ( + DeviceCategory.JWBJ: ( TuyaSensorEntityDescription( key=DPCode.CH4_SENSOR_VALUE, translation_key="methane", @@ -660,9 +626,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # Switch - # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s - "kg": ( + DeviceCategory.KG: ( TuyaSensorEntityDescription( key=DPCode.CUR_CURRENT, translation_key="current", @@ -699,9 +663,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.TOTAL_INCREASING, ), ), - # Air Purifier - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r41mn81 - "kj": ( + DeviceCategory.KJ: ( TuyaSensorEntityDescription( key=DPCode.FILTER, translation_key="filter_utilization", @@ -756,9 +718,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="air_quality", ), ), - # Luminance Sensor - # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 - "ldcg": ( + DeviceCategory.LDCG: ( TuyaSensorEntityDescription( key=DPCode.BRIGHT_STATE, translation_key="luminosity", @@ -790,15 +750,9 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # Door and Window Controller - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r5zjsy9 - "mc": BATTERY_SENSORS, - # Door Window Sensor - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48hm02l8m - "mcs": BATTERY_SENSORS, - # Cat toilet - # https://developer.tuya.com/en/docs/iot/s?id=Kakg3srr4ora7 - "msp": ( + DeviceCategory.MC: BATTERY_SENSORS, + DeviceCategory.MCS: BATTERY_SENSORS, + DeviceCategory.MSP: ( TuyaSensorEntityDescription( key=DPCode.CAT_WEIGHT, translation_key="cat_weight", @@ -806,9 +760,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), - # Sous Vide Cooker - # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux - "mzj": ( + DeviceCategory.MZJ: ( TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, translation_key="current_temperature", @@ -825,12 +777,8 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { native_unit_of_measurement=UnitOfTime.MINUTES, ), ), - # PIR Detector - # https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80 - "pir": BATTERY_SENSORS, - # PM2.5 Sensor - # https://developer.tuya.com/en/docs/iot/categorypm25?id=Kaiuz3qof3yfu - "pm2.5": ( + DeviceCategory.PIR: BATTERY_SENSORS, + DeviceCategory.PM2_5: ( TuyaSensorEntityDescription( key=DPCode.PM25_VALUE, translation_key="pm25", @@ -884,9 +832,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # Heater - # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm - "qn": ( + DeviceCategory.QN: ( TuyaSensorEntityDescription( key=DPCode.WORK_POWER, translation_key="power", @@ -894,9 +840,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), - # Temperature and Humidity Sensor with External Probe - # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 - "qxj": ( + DeviceCategory.QXJ: ( TuyaSensorEntityDescription( key=DPCode.VA_TEMPERATURE, translation_key="temperature", @@ -1018,9 +962,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # Gas Detector - # https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw - "rqbj": ( + DeviceCategory.RQBJ: ( TuyaSensorEntityDescription( key=DPCode.GAS_SENSOR_VALUE, name=None, @@ -1029,9 +971,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # Robot Vacuum - # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo - "sd": ( + DeviceCategory.SD: ( TuyaSensorEntityDescription( key=DPCode.CLEAN_AREA, translation_key="cleaning_area", @@ -1085,8 +1025,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), - # Smart Water Timer - "sfkzq": ( + DeviceCategory.SFKZQ: ( # Total seconds of irrigation. Read-write value; the device appears to ignore the write action (maybe firmware bug) TuyaSensorEntityDescription( key=DPCode.TIME_USE, @@ -1096,18 +1035,10 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # Siren Alarm - # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu - "sgbj": BATTERY_SENSORS, - # Water Detector - # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli - "sj": BATTERY_SENSORS, - # Emergency Button - # https://developer.tuya.com/en/docs/iot/categorysos?id=Kaiuz3oi6agjy - "sos": BATTERY_SENSORS, - # Smart Camera - # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 - "sp": ( + DeviceCategory.SGBJ: BATTERY_SENSORS, + DeviceCategory.SJ: BATTERY_SENSORS, + DeviceCategory.SOS: BATTERY_SENSORS, + DeviceCategory.SP: ( TuyaSensorEntityDescription( key=DPCode.SENSOR_TEMPERATURE, translation_key="temperature", @@ -1128,8 +1059,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), - # Cooking thermometer - "swtz": ( + DeviceCategory.SWTZ: ( TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, translation_key="temperature", @@ -1145,9 +1075,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # Smart Gardening system - # https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0 - "sz": ( + DeviceCategory.SZ: ( TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, translation_key="temperature", @@ -1161,8 +1089,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), - # Water tester - "szjcy": ( + DeviceCategory.SZJCY: ( TuyaSensorEntityDescription( key=DPCode.TDS_IN, translation_key="total_dissolved_solids", @@ -1176,11 +1103,8 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # Fingerbot - "szjqr": BATTERY_SENSORS, - # IoT Switch - # Note: Undocumented - "tdq": ( + DeviceCategory.SZJQR: BATTERY_SENSORS, + DeviceCategory.TDQ: ( TuyaSensorEntityDescription( key=DPCode.CUR_CURRENT, translation_key="current", @@ -1242,12 +1166,8 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # Solar Light - # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 - "tyndj": BATTERY_SENSORS, - # Volatile Organic Compound Sensor - # Note: Undocumented in cloud API docs, based on test device - "voc": ( + DeviceCategory.TYNDJ: BATTERY_SENSORS, + DeviceCategory.VOC: ( TuyaSensorEntityDescription( key=DPCode.CO2_VALUE, translation_key="carbon_dioxide", @@ -1287,13 +1207,8 @@ 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 - "wkcz": ( + DeviceCategory.WK: (*BATTERY_SENSORS,), + DeviceCategory.WKCZ: ( TuyaSensorEntityDescription( key=DPCode.HUMIDITY_VALUE, translation_key="humidity", @@ -1330,12 +1245,8 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { entity_registry_enabled_default=False, ), ), - # Thermostatic Radiator Valve - # Not documented - "wkf": BATTERY_SENSORS, - # eMylo Smart WiFi IR Remote - # Air Conditioner Mate (Smart IR Socket) - "wnykq": ( + DeviceCategory.WKF: BATTERY_SENSORS, + DeviceCategory.WNYKQ: ( TuyaSensorEntityDescription( key=DPCode.VA_TEMPERATURE, translation_key="temperature", @@ -1375,9 +1286,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { entity_registry_enabled_default=False, ), ), - # Temperature and Humidity Sensor - # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 - "wsdcg": ( + DeviceCategory.WSDCG: ( TuyaSensorEntityDescription( key=DPCode.VA_TEMPERATURE, translation_key="temperature", @@ -1410,12 +1319,8 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # Wireless Switch - # https://developer.tuya.com/en/docs/iot/s?id=Kbeoa9fkv6brp - "wxkg": BATTERY_SENSORS, # Pressure Sensor - # Micro Storage Inverter - # Energy storage and solar PV inverter system with monitoring capabilities - "xnyjcn": ( + DeviceCategory.WXKG: BATTERY_SENSORS, + DeviceCategory.XNYJCN: ( TuyaSensorEntityDescription( key=DPCode.CURRENT_SOC, translation_key="battery_soc", @@ -1486,8 +1391,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.TOTAL_INCREASING, ), ), - # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm - "ylcg": ( + DeviceCategory.YLCG: ( TuyaSensorEntityDescription( key=DPCode.PRESSURE_VALUE, name=None, @@ -1496,9 +1400,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # Smoke Detector - # https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952 - "ywbj": ( + DeviceCategory.YWBJ: ( TuyaSensorEntityDescription( key=DPCode.SMOKE_SENSOR_VALUE, translation_key="smoke_amount", @@ -1507,9 +1409,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # Tank Level Sensor - # Note: Undocumented - "ywcgq": ( + DeviceCategory.YWCGQ: ( TuyaSensorEntityDescription( key=DPCode.LIQUID_STATE, translation_key="liquid_state", @@ -1526,12 +1426,8 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), - # Vibration Sensor - # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno - "zd": BATTERY_SENSORS, - # Smart Electricity Meter - # https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7 - "zndb": ( + DeviceCategory.ZD: BATTERY_SENSORS, + DeviceCategory.ZNDB: ( TuyaSensorEntityDescription( key=DPCode.FORWARD_ENERGY_TOTAL, translation_key="total_energy", @@ -1647,8 +1543,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { subkey="voltage", ), ), - # VESKA-micro inverter - "znnbq": ( + DeviceCategory.ZNNBQ: ( TuyaSensorEntityDescription( key=DPCode.REVERSE_ENERGY_TOTAL, translation_key="total_energy", @@ -1671,8 +1566,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), - # Pool HeatPump - "znrb": ( + DeviceCategory.ZNRB: ( TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, translation_key="temperature", @@ -1680,8 +1574,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), - # Soil sensor (Plant monitor) - "zwjcy": ( + DeviceCategory.ZWJCY: ( TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, translation_key="temperature", @@ -1699,16 +1592,13 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { } # Socket (duplicate of `kg`) -# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s -SENSORS["cz"] = SENSORS["kg"] +SENSORS[DeviceCategory.CZ] = SENSORS[DeviceCategory.KG] # Smart Camera - Low power consumption camera (duplicate of `sp`) -# Undocumented, see https://github.com/home-assistant/core/issues/132844 -SENSORS["dghsxj"] = SENSORS["sp"] +SENSORS[DeviceCategory.DGHSXJ] = SENSORS[DeviceCategory.SP] # Power Socket (duplicate of `kg`) -# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s -SENSORS["pc"] = SENSORS["kg"] +SENSORS[DeviceCategory.PC] = SENSORS[DeviceCategory.KG] async def async_setup_entry( diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index d34123e02711..a12562b455fe 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -25,7 +25,7 @@ from homeassistant.helpers.issue_registry import ( ) from . import TuyaConfigEntry -from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode +from .const import DOMAIN, TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity @@ -40,10 +40,8 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): # All descriptions can be found here. Mostly the Boolean data types in the # default instruction set of each category end up being a Switch. # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { - # Smart Kettle - # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 - "bh": ( +SWITCHES: dict[DeviceCategory, tuple[SwitchEntityDescription, ...]] = { + DeviceCategory.BH: ( SwitchEntityDescription( key=DPCode.START, translation_key="start", @@ -54,8 +52,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # White noise machine - "bzyd": ( + DeviceCategory.BZYD: ( SwitchEntityDescription( key=DPCode.SWITCH, name=None, @@ -79,9 +76,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Curtain - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc - "cl": ( + DeviceCategory.CL: ( SwitchEntityDescription( key=DPCode.CONTROL_BACK, translation_key="reverse", @@ -93,9 +88,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # EasyBaby - # Undocumented, might have a wider use - "cn": ( + DeviceCategory.CN: ( SwitchEntityDescription( key=DPCode.DISINFECTION, translation_key="disinfection", @@ -105,9 +98,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="water", ), ), - # Dehumidifier - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r6jke8e - "cs": ( + DeviceCategory.CS: ( SwitchEntityDescription( key=DPCode.ANION, translation_key="ionizer", @@ -127,26 +118,20 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Smart Odor Eliminator-Pro - # Undocumented, see https://github.com/orgs/home-assistant/discussions/79 - "cwjwq": ( + DeviceCategory.CWJWQ: ( SwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", ), ), - # Smart Pet Feeder - # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld - "cwwsq": ( + DeviceCategory.CWWSQ: ( SwitchEntityDescription( key=DPCode.SLOW_FEED, translation_key="slow_feed", entity_category=EntityCategory.CONFIG, ), ), - # Pet Fountain - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46aewxem5 - "cwysj": ( + DeviceCategory.CWYSJ: ( SwitchEntityDescription( key=DPCode.FILTER_RESET, translation_key="filter_reset", @@ -172,9 +157,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Light - # https://developer.tuya.com/en/docs/iot/f?id=K9i5ql3v98hn3 - "dj": ( + DeviceCategory.DJ: ( # There are sockets available with an RGB light # that advertise as `dj`, but provide an additional # switch to control the plug. @@ -183,8 +166,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="plug", ), ), - # Circuit Breaker - "dlq": ( + DeviceCategory.DLQ: ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", @@ -195,9 +177,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="switch", ), ), - # Electric Blanket - # https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p - "dr": ( + DeviceCategory.DR: ( SwitchEntityDescription( key=DPCode.SWITCH, name="Power", @@ -235,9 +215,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { device_class=SwitchDeviceClass.SWITCH, ), ), - # Fan - # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c - "fs": ( + DeviceCategory.FS: ( SwitchEntityDescription( key=DPCode.ANION, translation_key="anion", @@ -269,18 +247,14 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Ceiling Fan Light - # https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v - "fsd": ( + DeviceCategory.FSD: ( SwitchEntityDescription( key=DPCode.FAN_BEEP, translation_key="sound", entity_category=EntityCategory.CONFIG, ), ), - # Irrigator - # https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k - "ggq": ( + DeviceCategory.GGQ: ( SwitchEntityDescription( key=DPCode.SWITCH_1, translation_key="indexed_switch", @@ -322,9 +296,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_placeholders={"index": "8"}, ), ), - # Wake Up Light II - # Not documented - "hxd": ( + DeviceCategory.HXD: ( SwitchEntityDescription( key=DPCode.SWITCH_1, translation_key="radio", @@ -358,9 +330,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="sleep_aid", ), ), - # Humidifier - # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b - "jsq": ( + DeviceCategory.JSQ: ( SwitchEntityDescription( key=DPCode.SWITCH_SOUND, translation_key="voice", @@ -377,9 +347,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Switch - # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s - "kg": ( + DeviceCategory.KG: ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", @@ -469,9 +437,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { device_class=SwitchDeviceClass.OUTLET, ), ), - # Air Purifier - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm - "kj": ( + DeviceCategory.KJ: ( SwitchEntityDescription( key=DPCode.ANION, translation_key="ionizer", @@ -502,9 +468,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Air conditioner - # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n - "kt": ( + DeviceCategory.KT: ( SwitchEntityDescription( key=DPCode.ANION, translation_key="ionizer", @@ -516,17 +480,13 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Undocumented tower fan - # https://github.com/orgs/home-assistant/discussions/329 - "ks": ( + DeviceCategory.KS: ( SwitchEntityDescription( key=DPCode.ANION, translation_key="ionizer", ), ), - # Alarm Host - # https://developer.tuya.com/en/docs/iot/alarm-hosts?id=K9gf48r87hyjk - "mal": ( + DeviceCategory.MAL: ( SwitchEntityDescription( key=DPCode.SWITCH_ALARM_SOUND, # This switch is called "Arm Beep" in the official Tuya app @@ -540,9 +500,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Sous Vide Cooker - # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux - "mzj": ( + DeviceCategory.MZJ: ( SwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", @@ -554,9 +512,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Power Socket - # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s - "pc": ( + DeviceCategory.PC: ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", @@ -634,26 +590,19 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { device_class=SwitchDeviceClass.OUTLET, ), ), - # AC charging - # Not documented - "qccdz": ( + DeviceCategory.QCCDZ: ( SwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", ), ), - # Unknown product with switch capabilities - # Fond in some diffusers, plugs and PIR flood lights - # Not documented - "qjdcz": ( + DeviceCategory.QJDCZ: ( SwitchEntityDescription( key=DPCode.SWITCH_1, translation_key="switch", ), ), - # Heater - # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm - "qn": ( + DeviceCategory.QN: ( SwitchEntityDescription( key=DPCode.ANION, translation_key="ionizer", @@ -665,18 +614,14 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # SIREN: Siren (switch) with Temperature and Humidity Sensor with External Probe - # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 - "qxj": ( + DeviceCategory.QXJ: ( SwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", device_class=SwitchDeviceClass.OUTLET, ), ), - # Robot Vacuum - # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo - "sd": ( + DeviceCategory.SD: ( SwitchEntityDescription( key=DPCode.SWITCH_DISTURB, translation_key="do_not_disturb", @@ -688,8 +633,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Smart Water Timer - "sfkzq": ( + DeviceCategory.SFKZQ: ( TuyaDeprecatedSwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", @@ -697,26 +641,21 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { breaks_in_ha_version="2026.4.0", ), ), - # Siren Alarm - # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu - "sgbj": ( + DeviceCategory.SGBJ: ( SwitchEntityDescription( key=DPCode.MUFFLING, translation_key="mute", entity_category=EntityCategory.CONFIG, ), ), - # Electric desk - "sjz": ( + DeviceCategory.SJZ: ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", entity_category=EntityCategory.CONFIG, ), ), - # Smart Camera - # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 - "sp": ( + DeviceCategory.SP: ( SwitchEntityDescription( key=DPCode.WIRELESS_BATTERYLOCK, translation_key="battery_lock", @@ -773,9 +712,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Smart Gardening system - # https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0 - "sz": ( + DeviceCategory.SZ: ( SwitchEntityDescription( key=DPCode.SWITCH, translation_key="power", @@ -785,16 +722,13 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="pump", ), ), - # Fingerbot - "szjqr": ( + DeviceCategory.SZJQR: ( SwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", ), ), - # IoT Switch? - # Note: Undocumented - "tdq": ( + DeviceCategory.TDQ: ( SwitchEntityDescription( key=DPCode.SWITCH_1, translation_key="indexed_switch", @@ -837,27 +771,21 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Solar Light - # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 - "tyndj": ( + DeviceCategory.TYNDJ: ( SwitchEntityDescription( key=DPCode.SWITCH_SAVE_ENERGY, translation_key="energy_saving", entity_category=EntityCategory.CONFIG, ), ), - # Gateway control - # https://developer.tuya.com/en/docs/iot/wg?id=Kbcdadk79ejok - "wg2": ( + DeviceCategory.WG2: ( SwitchEntityDescription( key=DPCode.MUFFLING, translation_key="mute", entity_category=EntityCategory.CONFIG, ), ), - # Thermostat - # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 - "wk": ( + DeviceCategory.WK: ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", @@ -869,10 +797,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Two-way temperature and humidity switch - # "MOES Temperature and Humidity Smart Switch Module MS-103" - # Documentation not found - "wkcz": ( + DeviceCategory.WKCZ: ( SwitchEntityDescription( key=DPCode.SWITCH_1, translation_key="indexed_switch", @@ -886,9 +811,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { device_class=SwitchDeviceClass.OUTLET, ), ), - # Thermostatic Radiator Valve - # Not documented - "wkf": ( + DeviceCategory.WKF: ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", @@ -900,43 +823,34 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Air Conditioner Mate (Smart IR Socket) - "wnykq": ( + DeviceCategory.WNYKQ: ( SwitchEntityDescription( key=DPCode.SWITCH, name=None, ), ), - # SIREN: Siren (switch) with Temperature and humidity sensor - # https://developer.tuya.com/en/docs/iot/f?id=Kavck4sr3o5ek - "wsdcg": ( + DeviceCategory.WSDCG: ( SwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", device_class=SwitchDeviceClass.OUTLET, ), ), - # Ceiling Light - # https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r - "xdd": ( + DeviceCategory.XDD: ( SwitchEntityDescription( key=DPCode.DO_NOT_DISTURB, translation_key="do_not_disturb", entity_category=EntityCategory.CONFIG, ), ), - # Micro Storage Inverter - # Energy storage and solar PV inverter system with monitoring capabilities - "xnyjcn": ( + DeviceCategory.XNYJCN: ( SwitchEntityDescription( key=DPCode.FEEDIN_POWER_LIMIT_ENABLE, translation_key="output_power_limit", entity_category=EntityCategory.CONFIG, ), ), - # Diffuser - # https://developer.tuya.com/en/docs/iot/categoryxxj?id=Kaiuz1f9mo6bl - "xxj": ( + DeviceCategory.XXJ: ( SwitchEntityDescription( key=DPCode.SWITCH, translation_key="power", @@ -951,32 +865,26 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Smoke Detector - # https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952 - "ywbj": ( + DeviceCategory.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": ( + DeviceCategory.ZNDB: ( SwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", ), ), - # Hejhome whitelabel Fingerbot - "znjxs": ( + DeviceCategory.ZNJXS: ( SwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", ), ), - # Pool HeatPump - "znrb": ( + DeviceCategory.ZNRB: ( SwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", @@ -985,12 +893,10 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { } # Socket (duplicate of `pc`) -# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s -SWITCHES["cz"] = SWITCHES["pc"] +SWITCHES[DeviceCategory.CZ] = SWITCHES[DeviceCategory.PC] # Smart Camera - Low power consumption camera (duplicate of `sp`) -# Undocumented, see https://github.com/home-assistant/core/issues/132844 -SWITCHES["dghsxj"] = SWITCHES["sp"] +SWITCHES[DeviceCategory.DGHSXJ] = SWITCHES[DeviceCategory.SP] async def async_setup_entry( From c4de46a85b2dc229e0924c34614e7146dc9c924a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Wed, 24 Sep 2025 17:41:36 +0200 Subject: [PATCH 124/189] Add number platform to LetPot integration (#151092) --- homeassistant/components/letpot/__init__.py | 1 + homeassistant/components/letpot/icons.json | 8 ++ homeassistant/components/letpot/number.py | 136 ++++++++++++++++++ homeassistant/components/letpot/strings.json | 11 +- .../letpot/snapshots/test_number.ambr | 116 +++++++++++++++ tests/components/letpot/test_number.py | 99 +++++++++++++ 6 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/letpot/number.py create mode 100644 tests/components/letpot/snapshots/test_number.ambr create mode 100644 tests/components/letpot/test_number.py diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py index 7bcb04b2b4d0..7e1687928877 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.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/letpot/icons.json b/homeassistant/components/letpot/icons.json index 1f5e79b04dd1..aac6326d0779 100644 --- a/homeassistant/components/letpot/icons.json +++ b/homeassistant/components/letpot/icons.json @@ -20,6 +20,14 @@ } } }, + "number": { + "light_brightness": { + "default": "mdi:brightness-5" + }, + "plant_days": { + "default": "mdi:calendar-blank" + } + }, "select": { "display_temperature_unit": { "default": "mdi:thermometer-lines" diff --git a/homeassistant/components/letpot/number.py b/homeassistant/components/letpot/number.py new file mode 100644 index 000000000000..a5b9c3df68c1 --- /dev/null +++ b/homeassistant/components/letpot/number.py @@ -0,0 +1,136 @@ +"""Support for LetPot number entities.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from letpot.deviceclient import LetPotDeviceClient +from letpot.models import DeviceFeature + +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import PRECISION_WHOLE, 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 + + +@dataclass(frozen=True, kw_only=True) +class LetPotNumberEntityDescription(LetPotEntityDescription, NumberEntityDescription): + """Describes a LetPot number entity.""" + + max_value_fn: Callable[[LetPotDeviceCoordinator], float] + value_fn: Callable[[LetPotDeviceCoordinator], float | None] + set_value_fn: Callable[[LetPotDeviceClient, str, float], Coroutine[Any, Any, None]] + + +NUMBERS: tuple[LetPotNumberEntityDescription, ...] = ( + LetPotNumberEntityDescription( + key="light_brightness_levels", + translation_key="light_brightness", + value_fn=( + lambda coordinator: coordinator.device_client.get_light_brightness_levels( + coordinator.device.serial_number + ).index(coordinator.data.light_brightness) + + 1 + if coordinator.data.light_brightness is not None + else None + ), + set_value_fn=( + lambda device_client, serial, value: device_client.set_light_brightness( + serial, + device_client.get_light_brightness_levels(serial)[int(value) - 1], + ) + ), + supported_fn=( + lambda coordinator: DeviceFeature.LIGHT_BRIGHTNESS_LEVELS + in coordinator.device_client.device_info( + coordinator.device.serial_number + ).features + ), + native_min_value=float(1), + max_value_fn=lambda coordinator: float( + len( + coordinator.device_client.get_light_brightness_levels( + coordinator.device.serial_number + ) + ) + ), + native_step=PRECISION_WHOLE, + mode=NumberMode.SLIDER, + entity_category=EntityCategory.CONFIG, + ), + LetPotNumberEntityDescription( + key="plant_days", + translation_key="plant_days", + value_fn=lambda coordinator: coordinator.data.plant_days, + set_value_fn=( + lambda device_client, serial, value: device_client.set_plant_days( + serial, int(value) + ) + ), + native_min_value=float(0), + max_value_fn=lambda _: float(999), + native_step=PRECISION_WHOLE, + mode=NumberMode.BOX, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LetPotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up LetPot number entities based on a config entry and device status/features.""" + coordinators = entry.runtime_data + async_add_entities( + LetPotNumberEntity(coordinator, description) + for description in NUMBERS + for coordinator in coordinators + if description.supported_fn(coordinator) + ) + + +class LetPotNumberEntity(LetPotEntity, NumberEntity): + """Defines a LetPot number entity.""" + + entity_description: LetPotNumberEntityDescription + + def __init__( + self, + coordinator: LetPotDeviceCoordinator, + description: LetPotNumberEntityDescription, + ) -> None: + """Initialize LetPot number 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 native_max_value(self) -> float: + """Return the maximum available value.""" + return self.entity_description.max_value_fn(self.coordinator) + + @property + def native_value(self) -> float | None: + """Return the number value.""" + return self.entity_description.value_fn(self.coordinator) + + @exception_handler + async def async_set_native_value(self, value: float) -> None: + """Change the number value.""" + return await self.entity_description.set_value_fn( + self.coordinator.device_client, + self.coordinator.device.serial_number, + value, + ) diff --git a/homeassistant/components/letpot/strings.json b/homeassistant/components/letpot/strings.json index 6ebd79edf5d7..4c46e1ddbb16 100644 --- a/homeassistant/components/letpot/strings.json +++ b/homeassistant/components/letpot/strings.json @@ -49,6 +49,15 @@ "name": "Refill error" } }, + "number": { + "light_brightness": { + "name": "Light brightness" + }, + "plant_days": { + "name": "Plants age", + "unit_of_measurement": "days" + } + }, "select": { "display_temperature_unit": { "name": "Temperature unit on display", @@ -58,7 +67,7 @@ } }, "light_brightness": { - "name": "Light brightness", + "name": "[%key:component::letpot::entity::number::light_brightness::name%]", "state": { "low": "[%key:common::state::low%]", "high": "[%key:common::state::high%]" diff --git a/tests/components/letpot/snapshots/test_number.ambr b/tests/components/letpot/snapshots/test_number.ambr new file mode 100644 index 000000000000..50f6cf64312e --- /dev/null +++ b/tests/components/letpot/snapshots/test_number.ambr @@ -0,0 +1,116 @@ +# serializer version: 1 +# name: test_all_entities[number.garden_light_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 8.0, + 'min': 1.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.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_LPH63ABCD_light_brightness_levels', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.garden_light_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Light brightness', + 'max': 8.0, + 'min': 1.0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.garden_light_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_all_entities[number.garden_plants_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 999.0, + 'min': 0.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.garden_plants_age', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Plants age', + 'platform': 'letpot', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plant_days', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_plant_days', + 'unit_of_measurement': 'days', + }) +# --- +# name: test_all_entities[number.garden_plants_age-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Plants age', + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1, + 'unit_of_measurement': 'days', + }), + 'context': , + 'entity_id': 'number.garden_plants_age', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- diff --git a/tests/components/letpot/test_number.py b/tests/components/letpot/test_number.py new file mode 100644 index 000000000000..423ac7c31940 --- /dev/null +++ b/tests/components/letpot/test_number.py @@ -0,0 +1,99 @@ +"""Test number entities for the LetPot integration.""" + +from unittest.mock import MagicMock, patch + +from letpot.exceptions import LetPotConnectionException, LetPotException +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_client: MagicMock, + mock_device_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test number entities.""" + with patch("homeassistant.components.letpot.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_set_number( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + device_type: str, +) -> None: + """Test number entity set to value.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.garden_light_brightness", + ATTR_VALUE: 6, + }, + blocking=True, + ) + + mock_device_client.set_light_brightness.assert_awaited_once_with( + f"{device_type}ABCD", 750 + ) + + +@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_number_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + exception: Exception, + user_error: str, +) -> None: + """Test number entity exception handling.""" + await setup_integration(hass, mock_config_entry) + + mock_device_client.set_plant_days.side_effect = exception + + with pytest.raises(HomeAssistantError, match=user_error): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.garden_plants_age", + ATTR_VALUE: 7, + }, + blocking=True, + ) From 19d87abb8afb7df384fb7d87f7fc862752c7dfc6 Mon Sep 17 00:00:00 2001 From: alorente Date: Wed, 24 Sep 2025 17:43:32 +0200 Subject: [PATCH 125/189] Add Q-Adapt to Airzone integration (#151945) --- homeassistant/components/airzone/select.py | 20 ++++++++++++++++++- homeassistant/components/airzone/strings.json | 10 ++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/airzone/select.py b/homeassistant/components/airzone/select.py index c00e83f2c5b3..813ead8b6a8d 100644 --- a/homeassistant/components/airzone/select.py +++ b/homeassistant/components/airzone/select.py @@ -6,17 +6,19 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any, Final -from aioairzone.common import GrilleAngle, OperationMode, SleepTimeout +from aioairzone.common import GrilleAngle, OperationMode, QAdapt, SleepTimeout from aioairzone.const import ( API_COLD_ANGLE, API_HEAT_ANGLE, API_MODE, + API_Q_ADAPT, API_SLEEP, AZD_COLD_ANGLE, AZD_HEAT_ANGLE, AZD_MASTER, AZD_MODE, AZD_MODES, + AZD_Q_ADAPT, AZD_SLEEP, AZD_ZONES, ) @@ -65,6 +67,14 @@ SLEEP_DICT: Final[dict[str, int]] = { "90m": SleepTimeout.SLEEP_90, } +Q_ADAPT_DICT: Final[dict[str, int]] = { + "standard": QAdapt.STANDARD, + "power": QAdapt.POWER, + "silence": QAdapt.SILENCE, + "minimum": QAdapt.MINIMUM, + "maximum": QAdapt.MAXIMUM, +} + def main_zone_options( zone_data: dict[str, Any], @@ -83,6 +93,14 @@ MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = ( options_fn=main_zone_options, translation_key="modes", ), + AirzoneSelectDescription( + api_param=API_Q_ADAPT, + entity_category=EntityCategory.CONFIG, + key=AZD_Q_ADAPT, + options=list(Q_ADAPT_DICT), + options_dict=Q_ADAPT_DICT, + translation_key="q_adapt", + ), ) diff --git a/homeassistant/components/airzone/strings.json b/homeassistant/components/airzone/strings.json index c7d9701aa837..0b783769803b 100644 --- a/homeassistant/components/airzone/strings.json +++ b/homeassistant/components/airzone/strings.json @@ -63,6 +63,16 @@ "stop": "Stop" } }, + "q_adapt": { + "name": "Q-Adapt", + "state": { + "standard": "Standard", + "power": "Power", + "silence": "Silence", + "minimum": "Minimum", + "maximum": "Maximum" + } + }, "sleep_times": { "name": "Sleep", "state": { From 79a2fc5a0171ca8f6556af14d553a473cf34bd5b Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Wed, 24 Sep 2025 17:51:04 +0200 Subject: [PATCH 126/189] Snapshot testing for Plugwise Select platform (#152827) --- .../plugwise/snapshots/test_select.ambr | 509 ++++++++++++++++++ tests/components/plugwise/test_select.py | 49 +- 2 files changed, 540 insertions(+), 18 deletions(-) create mode 100644 tests/components/plugwise/snapshots/test_select.ambr diff --git a/tests/components/plugwise/snapshots/test_select.ambr b/tests/components/plugwise/snapshots/test_select.ambr new file mode 100644 index 000000000000..c83e56a34460 --- /dev/null +++ b/tests/components/plugwise/snapshots/test_select.ambr @@ -0,0 +1,509 @@ +# serializer version: 1 +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.adam_gateway_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'away', + 'full', + 'vacation', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.adam_gateway_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': 'Gateway mode', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gateway_mode', + 'unique_id': 'da224107914542988a88561b4452b0f6-select_gateway_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.adam_gateway_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Adam Gateway mode', + 'options': list([ + 'away', + 'full', + 'vacation', + ]), + }), + 'context': , + 'entity_id': 'select.adam_gateway_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'full', + }) +# --- +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.adam_regulation_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'bleeding_hot', + 'bleeding_cold', + 'off', + 'heating', + 'cooling', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.adam_regulation_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': 'Regulation mode', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'regulation_mode', + 'unique_id': 'da224107914542988a88561b4452b0f6-select_regulation_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.adam_regulation_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Adam Regulation mode', + 'options': list([ + 'bleeding_hot', + 'bleeding_cold', + 'off', + 'heating', + 'cooling', + ]), + }), + 'context': , + 'entity_id': 'select.adam_regulation_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cooling', + }) +# --- +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.bathroom_thermostat_schedule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Badkamer', + 'Test', + 'Vakantie', + 'Weekschema', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.bathroom_thermostat_schedule', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat schedule', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'select_schedule', + 'unique_id': 'f871b8c4d63549319221e294e4f88074-select_schedule', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.bathroom_thermostat_schedule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bathroom Thermostat schedule', + 'options': list([ + 'Badkamer', + 'Test', + 'Vakantie', + 'Weekschema', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.bathroom_thermostat_schedule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Badkamer', + }) +# --- +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.living_room_thermostat_schedule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Badkamer', + 'Test', + 'Vakantie', + 'Weekschema', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.living_room_thermostat_schedule', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat schedule', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'select_schedule', + 'unique_id': 'f2bf9048bef64cc5b6d5110154e33c81-select_schedule', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.living_room_thermostat_schedule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living room Thermostat schedule', + 'options': list([ + 'Badkamer', + 'Test', + 'Vakantie', + 'Weekschema', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.living_room_thermostat_schedule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_adam_select_entities[platforms0][select.badkamer_thermostat_schedule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.badkamer_thermostat_schedule', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat schedule', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'select_schedule', + 'unique_id': '08963fec7c53423ca5680aa4cb502c63-select_schedule', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_select_entities[platforms0][select.badkamer_thermostat_schedule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Badkamer Thermostat schedule', + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.badkamer_thermostat_schedule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Badkamer Schema', + }) +# --- +# name: test_adam_select_entities[platforms0][select.bios_thermostat_schedule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.bios_thermostat_schedule', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat schedule', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'select_schedule', + 'unique_id': '12493538af164a409c6a1c79e38afe1c-select_schedule', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_select_entities[platforms0][select.bios_thermostat_schedule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bios Thermostat schedule', + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.bios_thermostat_schedule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_adam_select_entities[platforms0][select.jessie_thermostat_schedule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.jessie_thermostat_schedule', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat schedule', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'select_schedule', + 'unique_id': '82fa13f017d240daa0d0ea1775420f24-select_schedule', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_select_entities[platforms0][select.jessie_thermostat_schedule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Jessie Thermostat schedule', + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.jessie_thermostat_schedule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'CV Jessie', + }) +# --- +# name: test_adam_select_entities[platforms0][select.woonkamer_thermostat_schedule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.woonkamer_thermostat_schedule', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat schedule', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'select_schedule', + 'unique_id': 'c50f167537524366a5af7aa3942feb1e-select_schedule', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_select_entities[platforms0][select.woonkamer_thermostat_schedule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Woonkamer Thermostat schedule', + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.woonkamer_thermostat_schedule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'GF7 Woonkamer', + }) +# --- diff --git a/tests/components/plugwise/test_select.py b/tests/components/plugwise/test_select.py index f6c4205b756f..91ef44049fd1 100644 --- a/tests/components/plugwise/test_select.py +++ b/tests/components/plugwise/test_select.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.select import ( ATTR_OPTION, @@ -12,18 +13,22 @@ from homeassistant.components.select import ( from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform +@pytest.mark.parametrize("platforms", [(SELECT_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_adam_select_entities( - hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + mock_smile_adam: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test a thermostat Select.""" - - state = hass.states.get("select.woonkamer_thermostat_schedule") - assert state - assert state.state == "GF7 Woonkamer" + """Test Adam select snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) async def test_adam_change_select_entity( @@ -50,6 +55,21 @@ async def test_adam_change_select_entity( ) +@pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) +@pytest.mark.parametrize("platforms", [(SELECT_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_2_select_entities( + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test Adam with cooling select snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + @pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_adam_select_regulation_mode( @@ -57,17 +77,10 @@ async def test_adam_select_regulation_mode( mock_smile_adam_heat_cool: MagicMock, init_integration: MockConfigEntry, ) -> None: - """Test a regulation_mode select. + """Test changing the regulation_mode select. Also tests a change in climate _previous mode. """ - - state = hass.states.get("select.adam_gateway_mode") - assert state - assert state.state == "full" - state = hass.states.get("select.adam_regulation_mode") - assert state - assert state.state == "cooling" await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, @@ -97,10 +110,10 @@ async def test_legacy_anna_select_entities( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) -async def test_adam_select_unavailable_regulation_mode( +async def test_anna_select_unavailable_schedule_mode( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: - """Test a regulation_mode non-available preset.""" + """Fail-test an Anna thermostat_schedule select option.""" with pytest.raises(ServiceValidationError, match="valid options"): await hass.services.async_call( @@ -108,7 +121,7 @@ async def test_adam_select_unavailable_regulation_mode( SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: "select.anna_thermostat_schedule", - ATTR_OPTION: "freezing", + ATTR_OPTION: "Winter", }, blocking=True, ) From d865fcf9991f7ef6c7baeb7f495b3c624e9b8e28 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 24 Sep 2025 17:58:44 +0200 Subject: [PATCH 127/189] Do not include capabilities in extended analytics (#152900) Co-authored-by: Paulus Schoutsen --- .../components/analytics/analytics.py | 17 ++++------- .../components/input_select/analytics.py | 28 ------------------- tests/components/analytics/test_analytics.py | 17 ----------- 3 files changed, 6 insertions(+), 56 deletions(-) delete mode 100644 homeassistant/components/input_select/analytics.py diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 22e641c414a4..5795be4e0279 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -39,7 +39,7 @@ from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store from homeassistant.helpers.system_info import async_get_system_info -from homeassistant.helpers.typing import UNDEFINED, UndefinedType +from homeassistant.helpers.typing import UNDEFINED from homeassistant.loader import ( Integration, IntegrationNotFound, @@ -142,7 +142,6 @@ class EntityAnalyticsModifications: """ remove: bool = False - capabilities: dict[str, Any] | None | UndefinedType = UNDEFINED class AnalyticsPlatformProtocol(Protocol): @@ -677,18 +676,14 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # we should replace it with the original value in the future. # It is also not present, if entity is not in the state machine, # which can happen for disabled entities. - "assumed_state": entity_state.attributes.get(ATTR_ASSUMED_STATE, False) - if entity_state is not None - else None, - "capabilities": entity_config.capabilities - if entity_config.capabilities is not UNDEFINED - else entity_entry.capabilities, + "assumed_state": ( + entity_state.attributes.get(ATTR_ASSUMED_STATE, False) + if entity_state is not None + else None + ), "domain": entity_entry.domain, "entity_category": entity_entry.entity_category, "has_entity_name": entity_entry.has_entity_name, - "modified_by_integration": ["capabilities"] - if entity_config.capabilities is not UNDEFINED - else None, "original_device_class": entity_entry.original_device_class, # LIMITATION: `unit_of_measurement` can be overridden by users; # we should replace it with the original value in the future. diff --git a/homeassistant/components/input_select/analytics.py b/homeassistant/components/input_select/analytics.py deleted file mode 100644 index a543b822f47d..000000000000 --- a/homeassistant/components/input_select/analytics.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Analytics platform.""" - -from homeassistant.components.analytics import ( - AnalyticsInput, - AnalyticsModifications, - EntityAnalyticsModifications, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - - -async def async_modify_analytics( - hass: HomeAssistant, analytics_input: AnalyticsInput -) -> AnalyticsModifications: - """Modify the analytics.""" - ent_reg = er.async_get(hass) - - entities: dict[str, EntityAnalyticsModifications] = {} - for entity_id in analytics_input.entity_ids: - entity_entry = ent_reg.entities[entity_id] - if entity_entry.capabilities is not None: - capabilities = dict(entity_entry.capabilities) - capabilities["options"] = len(capabilities["options"]) - entities[entity_id] = EntityAnalyticsModifications( - capabilities=capabilities - ) - - return AnalyticsModifications(entities=entities) diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 4a98d9770e4f..876e34dae75e 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -1232,34 +1232,25 @@ async def test_devices_payload_with_entities( "entities": [ { "assumed_state": None, - "capabilities": { - "min_color_temp_kelvin": 2000, - "max_color_temp_kelvin": 6535, - }, "domain": "light", "entity_category": None, "has_entity_name": True, - "modified_by_integration": None, "original_device_class": None, "unit_of_measurement": None, }, { "assumed_state": False, - "capabilities": None, "domain": "number", "entity_category": "config", "has_entity_name": True, - "modified_by_integration": None, "original_device_class": "temperature", "unit_of_measurement": None, }, { "assumed_state": True, - "capabilities": None, "domain": "light", "entity_category": None, "has_entity_name": True, - "modified_by_integration": None, "original_device_class": None, "unit_of_measurement": None, }, @@ -1277,11 +1268,9 @@ async def test_devices_payload_with_entities( "entities": [ { "assumed_state": None, - "capabilities": None, "domain": "light", "entity_category": None, "has_entity_name": False, - "modified_by_integration": None, "original_device_class": None, "unit_of_measurement": None, }, @@ -1299,11 +1288,9 @@ async def test_devices_payload_with_entities( "entities": [ { "assumed_state": None, - "capabilities": {"state_class": "measurement"}, "domain": "sensor", "entity_category": None, "has_entity_name": False, - "modified_by_integration": None, "original_device_class": "temperature", "unit_of_measurement": "°C", }, @@ -1314,11 +1301,9 @@ async def test_devices_payload_with_entities( "entities": [ { "assumed_state": None, - "capabilities": None, "domain": "light", "entity_category": None, "has_entity_name": True, - "modified_by_integration": None, "original_device_class": None, "unit_of_measurement": None, }, @@ -1427,11 +1412,9 @@ async def test_analytics_platforms( "entities": [ { "assumed_state": None, - "capabilities": {"options": 2}, "domain": "sensor", "entity_category": None, "has_entity_name": False, - "modified_by_integration": ["capabilities"], "original_device_class": None, "unit_of_measurement": None, }, From 2844bd474ac70ea2b58f8491f50d017a3afdfbd1 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 24 Sep 2025 18:05:13 +0200 Subject: [PATCH 128/189] Update frontend to 20250924.0 (#152901) --- 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 44dff4502993..11e703cd73e4 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==20250903.5"] + "requirements": ["home-assistant-frontend==20250924.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index afc46ecbd6bb..36f01d11b695 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==5.6.4 hass-nabucasa==1.1.1 hassil==3.2.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250903.5 +home-assistant-frontend==20250924.0 home-assistant-intents==2025.9.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index d5a89bc7e6b6..c92bc0b3d1c3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1186,7 +1186,7 @@ hole==0.9.0 holidays==0.81 # homeassistant.components.frontend -home-assistant-frontend==20250903.5 +home-assistant-frontend==20250924.0 # homeassistant.components.conversation home-assistant-intents==2025.9.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 567484e9f225..5264bd7150e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1035,7 +1035,7 @@ hole==0.9.0 holidays==0.81 # homeassistant.components.frontend -home-assistant-frontend==20250903.5 +home-assistant-frontend==20250924.0 # homeassistant.components.conversation home-assistant-intents==2025.9.24 From 6dd33f900df67ce04eda1068c8ac712de83cc087 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 24 Sep 2025 18:07:23 +0200 Subject: [PATCH 129/189] Add support for Reolink chime connected to Home Hub (#151199) --- homeassistant/components/reolink/entity.py | 45 +++++++++++++-- homeassistant/components/reolink/number.py | 38 ++++++++++++- homeassistant/components/reolink/select.py | 65 +++++++++++++++------- homeassistant/components/reolink/switch.py | 44 ++++++++++++++- tests/components/reolink/conftest.py | 1 + tests/components/reolink/test_number.py | 3 + tests/components/reolink/test_select.py | 5 ++ tests/components/reolink/test_switch.py | 4 ++ 8 files changed, 176 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 7d290dc6f0ad..dcda6b843ad1 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -243,8 +243,45 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): await super().async_will_remove_from_hass() +class ReolinkHostChimeCoordinatorEntity(ReolinkHostCoordinatorEntity): + """Parent class for Reolink chime entities connected to a Host.""" + + def __init__( + self, + reolink_data: ReolinkData, + chime: Chime, + coordinator: DataUpdateCoordinator[None] | None = None, + ) -> None: + """Initialize ReolinkChimeCoordinatorEntity for a chime.""" + super().__init__(reolink_data, coordinator) + self._channel = chime.channel + self._chime = chime + + self._attr_unique_id = ( + f"{self._host.unique_id}_chime{chime.dev_id}_{self.entity_description.key}" + ) + via_dev_id = self._host.unique_id + self._dev_id = f"{self._host.unique_id}_chime{chime.dev_id}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._dev_id)}, + via_device=(DOMAIN, via_dev_id), + name=chime.name, + model="Reolink Chime", + manufacturer=self._host.api.manufacturer, + sw_version=chime.sw_version, + serial_number=str(chime.dev_id), + configuration_url=self._conf_url, + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._chime.online + + class ReolinkChimeCoordinatorEntity(ReolinkChannelCoordinatorEntity): - """Parent class for Reolink chime entities connected.""" + """Parent class for Reolink chime entities connected through a camera.""" def __init__( self, @@ -255,21 +292,21 @@ class ReolinkChimeCoordinatorEntity(ReolinkChannelCoordinatorEntity): """Initialize ReolinkChimeCoordinatorEntity for a chime.""" assert chime.channel is not None super().__init__(reolink_data, chime.channel, coordinator) - self._chime = chime self._attr_unique_id = ( f"{self._host.unique_id}_chime{chime.dev_id}_{self.entity_description.key}" ) - cam_dev_id = self._dev_id + via_dev_id = self._dev_id self._dev_id = f"{self._host.unique_id}_chime{chime.dev_id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._dev_id)}, - via_device=(DOMAIN, cam_dev_id), + via_device=(DOMAIN, via_dev_id), name=chime.name, model="Reolink Chime", manufacturer=self._host.api.manufacturer, + sw_version=chime.sw_version, serial_number=str(chime.dev_id), configuration_url=self._conf_url, ) diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index e7575c207e99..aaf503d70f8a 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -23,6 +23,7 @@ from .entity import ( ReolinkChannelEntityDescription, ReolinkChimeCoordinatorEntity, ReolinkChimeEntityDescription, + ReolinkHostChimeCoordinatorEntity, ReolinkHostCoordinatorEntity, ReolinkHostEntityDescription, ) @@ -855,6 +856,12 @@ async def async_setup_entry( for chime in api.chime_list if chime.channel is not None ) + entities.extend( + ReolinkHostChimeNumberEntity(reolink_data, chime, entity_description) + for entity_description in CHIME_NUMBER_ENTITIES + for chime in api.chime_list + if chime.channel is None + ) async_add_entities(entities) @@ -969,7 +976,36 @@ class ReolinkHostNumberEntity(ReolinkHostCoordinatorEntity, NumberEntity): class ReolinkChimeNumberEntity(ReolinkChimeCoordinatorEntity, NumberEntity): - """Base number entity class for Reolink IP cameras.""" + """Base number entity class for Reolink chimes connected through a camera.""" + + entity_description: ReolinkChimeNumberEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + chime: Chime, + entity_description: ReolinkChimeNumberEntityDescription, + ) -> None: + """Initialize Reolink chime number entity.""" + self.entity_description = entity_description + super().__init__(reolink_data, chime) + + self._attr_mode = entity_description.mode + + @property + def native_value(self) -> float | None: + """State of the number entity.""" + return self.entity_description.value(self._chime) + + @raise_translated_error + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + await self.entity_description.method(self._chime, value) + self.async_write_ha_state() + + +class ReolinkHostChimeNumberEntity(ReolinkHostChimeCoordinatorEntity, NumberEntity): + """Base number entity class for Reolink chimes connected to the host.""" entity_description: ReolinkChimeNumberEntityDescription diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 7c9510387992..4ce7866625d7 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -31,6 +31,7 @@ from .entity import ( ReolinkChannelEntityDescription, ReolinkChimeCoordinatorEntity, ReolinkChimeEntityDescription, + ReolinkHostChimeCoordinatorEntity, ReolinkHostCoordinatorEntity, ReolinkHostEntityDescription, ) @@ -73,7 +74,7 @@ class ReolinkChimeSelectEntityDescription( get_options: list[str] method: Callable[[Chime, str], Any] - value: Callable[[Chime], str] + value: Callable[[Chime], str | None] def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int: @@ -332,7 +333,7 @@ CHIME_SELECT_ENTITIES = ( entity_category=EntityCategory.CONFIG, supported=lambda chime: "md" in chime.chime_event_types, get_options=[method.name for method in ChimeToneEnum], - value=lambda chime: ChimeToneEnum(chime.tone("md")).name, + value=lambda chime: chime.tone_name("md"), method=lambda chime, name: chime.set_tone("md", ChimeToneEnum[name].value), ), ReolinkChimeSelectEntityDescription( @@ -342,7 +343,7 @@ CHIME_SELECT_ENTITIES = ( entity_category=EntityCategory.CONFIG, get_options=[method.name for method in ChimeToneEnum], supported=lambda chime: "people" in chime.chime_event_types, - value=lambda chime: ChimeToneEnum(chime.tone("people")).name, + value=lambda chime: chime.tone_name("people"), method=lambda chime, name: chime.set_tone("people", ChimeToneEnum[name].value), ), ReolinkChimeSelectEntityDescription( @@ -352,7 +353,7 @@ CHIME_SELECT_ENTITIES = ( entity_category=EntityCategory.CONFIG, get_options=[method.name for method in ChimeToneEnum], supported=lambda chime: "vehicle" in chime.chime_event_types, - value=lambda chime: ChimeToneEnum(chime.tone("vehicle")).name, + value=lambda chime: chime.tone_name("vehicle"), method=lambda chime, name: chime.set_tone("vehicle", ChimeToneEnum[name].value), ), ReolinkChimeSelectEntityDescription( @@ -362,7 +363,7 @@ CHIME_SELECT_ENTITIES = ( entity_category=EntityCategory.CONFIG, get_options=[method.name for method in ChimeToneEnum], supported=lambda chime: "visitor" in chime.chime_event_types, - value=lambda chime: ChimeToneEnum(chime.tone("visitor")).name, + value=lambda chime: chime.tone_name("visitor"), method=lambda chime, name: chime.set_tone("visitor", ChimeToneEnum[name].value), ), ReolinkChimeSelectEntityDescription( @@ -372,7 +373,7 @@ CHIME_SELECT_ENTITIES = ( entity_category=EntityCategory.CONFIG, get_options=[method.name for method in ChimeToneEnum], supported=lambda chime: "package" in chime.chime_event_types, - value=lambda chime: ChimeToneEnum(chime.tone("package")).name, + value=lambda chime: chime.tone_name("package"), method=lambda chime, name: chime.set_tone("package", ChimeToneEnum[name].value), ), ) @@ -386,9 +387,7 @@ async def async_setup_entry( """Set up a Reolink select entities.""" reolink_data: ReolinkData = config_entry.runtime_data - entities: list[ - ReolinkSelectEntity | ReolinkHostSelectEntity | ReolinkChimeSelectEntity - ] = [ + entities: list[SelectEntity] = [ ReolinkSelectEntity(reolink_data, channel, entity_description) for entity_description in SELECT_ENTITIES for channel in reolink_data.host.api.channels @@ -405,6 +404,12 @@ async def async_setup_entry( for chime in reolink_data.host.api.chime_list if entity_description.supported(chime) and chime.channel is not None ) + entities.extend( + ReolinkHostChimeSelectEntity(reolink_data, chime, entity_description) + for entity_description in CHIME_SELECT_ENTITIES + for chime in reolink_data.host.api.chime_list + if entity_description.supported(chime) and chime.channel is None + ) async_add_entities(entities) @@ -481,7 +486,7 @@ class ReolinkHostSelectEntity(ReolinkHostCoordinatorEntity, SelectEntity): class ReolinkChimeSelectEntity(ReolinkChimeCoordinatorEntity, SelectEntity): - """Base select entity class for Reolink IP cameras.""" + """Base select entity class for Reolink chimes connected through a camera.""" entity_description: ReolinkChimeSelectEntityDescription @@ -494,22 +499,40 @@ class ReolinkChimeSelectEntity(ReolinkChimeCoordinatorEntity, SelectEntity): """Initialize Reolink select entity for a chime.""" self.entity_description = entity_description super().__init__(reolink_data, chime) - self._log_error = True self._attr_options = entity_description.get_options @property def current_option(self) -> str | None: """Return the current option.""" - try: - option = self.entity_description.value(self._chime) - except (ValueError, KeyError): - if self._log_error: - _LOGGER.exception("Reolink '%s' has an unknown value", self.name) - self._log_error = False - return None - - self._log_error = True - return option + return self.entity_description.value(self._chime) + + @raise_translated_error + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.method(self._chime, option) + self.async_write_ha_state() + + +class ReolinkHostChimeSelectEntity(ReolinkHostChimeCoordinatorEntity, SelectEntity): + """Base select entity class for Reolink chimes connected to a host.""" + + entity_description: ReolinkChimeSelectEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + chime: Chime, + entity_description: ReolinkChimeSelectEntityDescription, + ) -> None: + """Initialize Reolink select entity for a chime.""" + self.entity_description = entity_description + super().__init__(reolink_data, chime) + self._attr_options = entity_description.get_options + + @property + def current_option(self) -> str | None: + """Return the current option.""" + return self.entity_description.value(self._chime) @raise_translated_error async def async_select_option(self, option: str) -> None: diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index bf18be7b837f..d5f45872661e 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -20,6 +20,7 @@ from .entity import ( ReolinkChannelEntityDescription, ReolinkChimeCoordinatorEntity, ReolinkChimeEntityDescription, + ReolinkHostChimeCoordinatorEntity, ReolinkHostCoordinatorEntity, ReolinkHostEntityDescription, ) @@ -364,9 +365,7 @@ async def async_setup_entry( """Set up a Reolink switch entities.""" reolink_data: ReolinkData = config_entry.runtime_data - entities: list[ - ReolinkSwitchEntity | ReolinkNVRSwitchEntity | ReolinkChimeSwitchEntity - ] = [ + entities: list[SwitchEntity] = [ ReolinkSwitchEntity(reolink_data, channel, entity_description) for entity_description in SWITCH_ENTITIES for channel in reolink_data.host.api.channels @@ -383,6 +382,12 @@ async def async_setup_entry( for chime in reolink_data.host.api.chime_list if chime.channel is not None ) + entities.extend( + ReolinkHostChimeSwitchEntity(reolink_data, chime, entity_description) + for entity_description in CHIME_SWITCH_ENTITIES + for chime in reolink_data.host.api.chime_list + if chime.channel is None + ) # Can be removed in HA 2025.4.0 depricated_dict = {} @@ -511,3 +516,36 @@ class ReolinkChimeSwitchEntity(ReolinkChimeCoordinatorEntity, SwitchEntity): """Turn the entity off.""" await self.entity_description.method(self._chime, False) self.async_write_ha_state() + + +class ReolinkHostChimeSwitchEntity(ReolinkHostChimeCoordinatorEntity, SwitchEntity): + """Base switch entity class for a chime.""" + + entity_description: ReolinkChimeSwitchEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + chime: Chime, + entity_description: ReolinkChimeSwitchEntityDescription, + ) -> None: + """Initialize Reolink switch entity.""" + self.entity_description = entity_description + super().__init__(reolink_data, chime) + + @property + def is_on(self) -> bool | None: + """Return true if switch is on.""" + return self.entity_description.value(self._chime) + + @raise_translated_error + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.entity_description.method(self._chime, True) + self.async_write_ha_state() + + @raise_translated_error + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self.entity_description.method(self._chime, False) + self.async_write_ha_state() diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 2911c851dae1..f40bfa839852 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -252,6 +252,7 @@ def reolink_chime(reolink_host: MagicMock) -> None: } TEST_CHIME.remove = AsyncMock() TEST_CHIME.set_option = AsyncMock() + TEST_CHIME.update_enums() reolink_host.chime_list = [TEST_CHIME] reolink_host.chime.return_value = TEST_CHIME diff --git a/tests/components/reolink/test_number.py b/tests/components/reolink/test_number.py index 853edeefa5a1..3e49a5dd4a78 100644 --- a/tests/components/reolink/test_number.py +++ b/tests/components/reolink/test_number.py @@ -147,13 +147,16 @@ async def test_host_number( ) +@pytest.mark.parametrize("channel", [0, None]) async def test_chime_number( hass: HomeAssistant, config_entry: MockConfigEntry, reolink_host: MagicMock, reolink_chime: Chime, + channel: int | None, ) -> None: """Test number entity of a chime with chime volume.""" + reolink_chime.channel = channel reolink_chime.volume = 3 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.NUMBER]): diff --git a/tests/components/reolink/test_select.py b/tests/components/reolink/test_select.py index 5dcce7475186..e74bcf8fc753 100644 --- a/tests/components/reolink/test_select.py +++ b/tests/components/reolink/test_select.py @@ -149,6 +149,7 @@ async def test_host_scene_select( assert hass.states.get(entity_id).state == STATE_UNKNOWN +@pytest.mark.parametrize("channel", [0, None]) async def test_chime_select( hass: HomeAssistant, freezer: FrozenDateTimeFactory, @@ -156,8 +157,11 @@ async def test_chime_select( reolink_host: MagicMock, reolink_chime: Chime, entity_registry: er.EntityRegistry, + channel: int | None, ) -> None: """Test chime select entity.""" + reolink_chime.channel = channel + 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() @@ -197,6 +201,7 @@ async def test_chime_select( # Test unavailable reolink_chime.event_info = {} + reolink_chime.update_enums() freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/reolink/test_switch.py b/tests/components/reolink/test_switch.py index c8a38f19d5ca..97dfc622aed0 100644 --- a/tests/components/reolink/test_switch.py +++ b/tests/components/reolink/test_switch.py @@ -164,14 +164,18 @@ async def test_host_switch( ) +@pytest.mark.parametrize("channel", [0, None]) async def test_chime_switch( hass: HomeAssistant, config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, reolink_host: MagicMock, reolink_chime: Chime, + channel: int | None, ) -> None: """Test host switch entity.""" + reolink_chime.channel = channel + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From 3a806d66037a445a36aeb6ecde3b0f1ba23f6ce6 Mon Sep 17 00:00:00 2001 From: Karsten Bade Date: Wed, 24 Sep 2025 18:23:58 +0200 Subject: [PATCH 130/189] Add dc:title support for Sonos sharelinks (#152774) Co-authored-by: Pete Sage <76050312+PeteRager@users.noreply.github.com> --- .../components/sonos/media_player.py | 62 +++++++++++++------ tests/components/sonos/test_media_player.py | 10 +++ 2 files changed, 52 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index a47c05a735a4..a21aca70d2ec 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -26,6 +26,7 @@ from homeassistant.components.media_player import ( ATTR_MEDIA_ARTIST, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_ENQUEUE, + ATTR_MEDIA_EXTRA, ATTR_MEDIA_TITLE, BrowseMedia, MediaPlayerDeviceClass, @@ -538,26 +539,14 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): share_link = self.coordinator.share_link if share_link.is_share_link(media_id): - if enqueue == MediaPlayerEnqueue.ADD: - share_link.add_share_link_to_queue( - media_id, timeout=LONG_SERVICE_TIMEOUT - ) - elif enqueue in ( - MediaPlayerEnqueue.NEXT, - MediaPlayerEnqueue.PLAY, - ): - pos = (self.media.queue_position or 0) + 1 - new_pos = share_link.add_share_link_to_queue( - media_id, position=pos, timeout=LONG_SERVICE_TIMEOUT - ) - if enqueue == MediaPlayerEnqueue.PLAY: - soco.play_from_queue(new_pos - 1) - elif enqueue == MediaPlayerEnqueue.REPLACE: - soco.clear_queue() - share_link.add_share_link_to_queue( - media_id, timeout=LONG_SERVICE_TIMEOUT - ) - soco.play_from_queue(0) + title = kwargs.get(ATTR_MEDIA_EXTRA, {}).get("title", "") + self._play_media_sharelink( + soco=soco, + media_type=media_type, + media_id=media_id, + enqueue=enqueue, + title=title, + ) elif media_type == MEDIA_TYPE_DIRECTORY: self._play_media_directory( soco=soco, media_type=media_type, media_id=media_id, enqueue=enqueue @@ -663,6 +652,39 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): ) self._play_media_queue(soco, item, enqueue) + def _play_media_sharelink( + self, + soco: SoCo, + media_type: MediaType | str, + media_id: str, + enqueue: MediaPlayerEnqueue, + title: str, + ) -> None: + share_link = self.coordinator.share_link + kwargs = {} + if title: + kwargs["dc_title"] = title + if enqueue == MediaPlayerEnqueue.ADD: + share_link.add_share_link_to_queue( + media_id, timeout=LONG_SERVICE_TIMEOUT, **kwargs + ) + elif enqueue in ( + MediaPlayerEnqueue.NEXT, + MediaPlayerEnqueue.PLAY, + ): + pos = (self.media.queue_position or 0) + 1 + new_pos = share_link.add_share_link_to_queue( + media_id, position=pos, timeout=LONG_SERVICE_TIMEOUT, **kwargs + ) + if enqueue == MediaPlayerEnqueue.PLAY: + soco.play_from_queue(new_pos - 1) + elif enqueue == MediaPlayerEnqueue.REPLACE: + soco.clear_queue() + share_link.add_share_link_to_queue( + media_id, timeout=LONG_SERVICE_TIMEOUT, **kwargs + ) + soco.play_from_queue(0) + @soco_error() def set_sleep_timer(self, sleep_time: int) -> None: """Set the timer on the player.""" diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 9f7871827fe2..e751fafca242 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -415,6 +415,7 @@ async def test_play_media_lib_track_add( _share_link: str = "spotify:playlist:abcdefghij0123456789XY" +_share_link_title: str = "playlist title" async def test_play_media_share_link_add( @@ -432,6 +433,7 @@ async def test_play_media_share_link_add( ATTR_MEDIA_CONTENT_TYPE: "playlist", ATTR_MEDIA_CONTENT_ID: _share_link, ATTR_MEDIA_ENQUEUE: MediaPlayerEnqueue.ADD, + ATTR_MEDIA_EXTRA: {"title": _share_link_title}, }, blocking=True, ) @@ -443,6 +445,10 @@ async def test_play_media_share_link_add( soco_sharelink.add_share_link_to_queue.call_args_list[0].kwargs["timeout"] == LONG_SERVICE_TIMEOUT ) + assert ( + soco_sharelink.add_share_link_to_queue.call_args_list[0].kwargs["dc_title"] + == _share_link_title + ) async def test_play_media_share_link_next( @@ -474,6 +480,10 @@ async def test_play_media_share_link_next( assert ( soco_sharelink.add_share_link_to_queue.call_args_list[0].kwargs["position"] == 1 ) + assert ( + "dc_title" + not in soco_sharelink.add_share_link_to_queue.call_args_list[0].kwargs + ) async def test_play_media_share_link_play( From 7b5314605c2484e5f729e024fe1093a8047bce04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 24 Sep 2025 17:25:01 +0100 Subject: [PATCH 131/189] Revert "Rename function arguments in modbus (#152814)" (#152904) --- homeassistant/components/modbus/light.py | 8 ++++---- homeassistant/components/modbus/modbus.py | 24 ++++++++++------------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index 36b8f4415b82..4c27ffb456b6 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -117,7 +117,7 @@ class ModbusLight(ModbusToggleEntity, LightEntity): conv_brightness = self._convert_brightness_to_modbus(brightness) await self._hub.async_pb_call( - device_address=self._device_address, + unit=self._device_address, address=self._brightness_address, value=conv_brightness, use_call=CALL_TYPE_WRITE_REGISTER, @@ -133,7 +133,7 @@ class ModbusLight(ModbusToggleEntity, LightEntity): conv_color_temp_kelvin = self._convert_color_temp_to_modbus(color_temp_kelvin) await self._hub.async_pb_call( - device_address=self._device_address, + unit=self._device_address, address=self._color_temp_address, value=conv_color_temp_kelvin, use_call=CALL_TYPE_WRITE_REGISTER, @@ -150,7 +150,7 @@ class ModbusLight(ModbusToggleEntity, LightEntity): if self._brightness_address: brightness_result = await self._hub.async_pb_call( - device_address=self._device_address, + unit=self._device_address, value=1, address=self._brightness_address, use_call=CALL_TYPE_REGISTER_HOLDING, @@ -167,7 +167,7 @@ class ModbusLight(ModbusToggleEntity, LightEntity): if self._color_temp_address: color_result = await self._hub.async_pb_call( - device_address=self._device_address, + unit=self._device_address, value=1, address=self._color_temp_address, use_call=CALL_TYPE_REGISTER_HOLDING, diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 89cdb7d47e44..467ccd6d8216 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -370,17 +370,11 @@ class ModbusHub: _LOGGER.info(f"modbus {self.name} communication closed") async def low_level_pb_call( - self, - device_address: int | None, - address: int, - value: int | list[int], - use_call: str, + self, slave: int | None, address: int, value: int | list[int], use_call: str ) -> ModbusPDU | None: """Call sync. pymodbus.""" kwargs: dict[str, Any] = ( - {DEVICE_ID: device_address} - if device_address is not None - else {DEVICE_ID: 1} + {DEVICE_ID: slave} if slave is not None else {DEVICE_ID: 1} ) entry = self._pb_request[use_call] @@ -392,26 +386,28 @@ class ModbusHub: try: result: ModbusPDU = await entry.func(address, **kwargs) except ModbusException as exception_error: - error = f"Error: device: {device_address} address: {address} -> {exception_error!s}" + error = f"Error: device: {slave} address: {address} -> {exception_error!s}" self._log_error(error) return None if not result: - error = f"Error: device: {device_address} address: {address} -> pymodbus returned None" + error = ( + f"Error: device: {slave} address: {address} -> pymodbus returned None" + ) self._log_error(error) return None if not hasattr(result, entry.attr): - error = f"Error: device: {device_address} address: {address} -> {result!s}" + error = f"Error: device: {slave} address: {address} -> {result!s}" self._log_error(error) return None if result.isError(): - error = f"Error: device: {device_address} address: {address} -> pymodbus returned isError True" + error = f"Error: device: {slave} address: {address} -> pymodbus returned isError True" self._log_error(error) return None return result async def async_pb_call( self, - device_address: int | None, + unit: int | None, address: int, value: int | list[int], use_call: str, @@ -419,7 +415,7 @@ class ModbusHub: """Convert async to sync pymodbus call.""" if not self._client: return None - result = await self.low_level_pb_call(device_address, address, value, use_call) + result = await self.low_level_pb_call(unit, address, value, use_call) if self._msg_wait: await asyncio.sleep(self._msg_wait) return result From c3ba086fad3e6991c41b784e02cf6dcbca0bc93e Mon Sep 17 00:00:00 2001 From: Kinachi249 <69488840+Kinachi249@users.noreply.github.com> Date: Wed, 24 Sep 2025 11:43:50 -0500 Subject: [PATCH 132/189] Add new Cync by GE integration (#149848) Co-authored-by: Joostlek --- CODEOWNERS | 2 + homeassistant/components/cync/__init__.py | 58 ++++ homeassistant/components/cync/config_flow.py | 118 ++++++++ homeassistant/components/cync/const.py | 9 + homeassistant/components/cync/coordinator.py | 87 ++++++ homeassistant/components/cync/entity.py | 45 +++ homeassistant/components/cync/light.py | 180 ++++++++++++ homeassistant/components/cync/manifest.json | 11 + .../components/cync/quality_scale.yaml | 69 +++++ homeassistant/components/cync/strings.json | 32 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/cync/__init__.py | 15 + tests/components/cync/conftest.py | 91 ++++++ tests/components/cync/const.py | 14 + tests/components/cync/fixtures/home.json | 76 +++++ .../components/cync/snapshots/test_light.ambr | 233 ++++++++++++++++ tests/components/cync/test_config_flow.py | 260 ++++++++++++++++++ tests/components/cync/test_light.py | 23 ++ 21 files changed, 1336 insertions(+) create mode 100644 homeassistant/components/cync/__init__.py create mode 100644 homeassistant/components/cync/config_flow.py create mode 100644 homeassistant/components/cync/const.py create mode 100644 homeassistant/components/cync/coordinator.py create mode 100644 homeassistant/components/cync/entity.py create mode 100644 homeassistant/components/cync/light.py create mode 100644 homeassistant/components/cync/manifest.json create mode 100644 homeassistant/components/cync/quality_scale.yaml create mode 100644 homeassistant/components/cync/strings.json create mode 100644 tests/components/cync/__init__.py create mode 100644 tests/components/cync/conftest.py create mode 100644 tests/components/cync/const.py create mode 100644 tests/components/cync/fixtures/home.json create mode 100644 tests/components/cync/snapshots/test_light.ambr create mode 100644 tests/components/cync/test_config_flow.py create mode 100644 tests/components/cync/test_light.py diff --git a/CODEOWNERS b/CODEOWNERS index c68c96f4f246..5a130d0278bd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -316,6 +316,8 @@ build.json @home-assistant/supervisor /tests/components/crownstone/ @Crownstone @RicArch97 /homeassistant/components/cups/ @fabaff /tests/components/cups/ @fabaff +/homeassistant/components/cync/ @Kinachi249 +/tests/components/cync/ @Kinachi249 /homeassistant/components/daikin/ @fredrike /tests/components/daikin/ @fredrike /homeassistant/components/date/ @home-assistant/core diff --git a/homeassistant/components/cync/__init__.py b/homeassistant/components/cync/__init__.py new file mode 100644 index 000000000000..a2fa7ad509a8 --- /dev/null +++ b/homeassistant/components/cync/__init__.py @@ -0,0 +1,58 @@ +"""The Cync integration.""" + +from __future__ import annotations + +from pycync import Auth, Cync, User +from pycync.exceptions import AuthFailedError, CyncError + +from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_AUTHORIZE_STRING, + CONF_EXPIRES_AT, + CONF_REFRESH_TOKEN, + CONF_USER_ID, +) +from .coordinator import CyncConfigEntry, CyncCoordinator + +_PLATFORMS: list[Platform] = [Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool: + """Set up Cync from a config entry.""" + user_info = User( + entry.data[CONF_ACCESS_TOKEN], + entry.data[CONF_REFRESH_TOKEN], + entry.data[CONF_AUTHORIZE_STRING], + entry.data[CONF_USER_ID], + expires_at=entry.data[CONF_EXPIRES_AT], + ) + cync_auth = Auth(async_get_clientsession(hass), user=user_info) + + try: + cync = await Cync.create(cync_auth) + except AuthFailedError as ex: + raise ConfigEntryAuthFailed("User token invalid") from ex + except CyncError as ex: + raise ConfigEntryNotReady("Unable to connect to Cync") from ex + + devices_coordinator = CyncCoordinator(hass, entry, cync) + + cync.set_update_callback(devices_coordinator.on_data_update) + + await devices_coordinator.async_config_entry_first_refresh() + entry.runtime_data = devices_coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool: + """Unload a config entry.""" + cync = entry.runtime_data.cync + await cync.shut_down() + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/cync/config_flow.py b/homeassistant/components/cync/config_flow.py new file mode 100644 index 000000000000..b10f1c03cc38 --- /dev/null +++ b/homeassistant/components/cync/config_flow.py @@ -0,0 +1,118 @@ +"""Config flow for the Cync integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pycync import Auth +from pycync.exceptions import AuthFailedError, CyncError, TwoFactorRequiredError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_AUTHORIZE_STRING, + CONF_EXPIRES_AT, + CONF_REFRESH_TOKEN, + CONF_TWO_FACTOR_CODE, + CONF_USER_ID, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } +) + +STEP_TWO_FACTOR_SCHEMA = vol.Schema({vol.Required(CONF_TWO_FACTOR_CODE): str}) + + +class CyncConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Cync.""" + + VERSION = 1 + + cync_auth: Auth + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Attempt login with user credentials.""" + errors: dict[str, str] = {} + + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + self.cync_auth = Auth( + async_get_clientsession(self.hass), + username=user_input[CONF_EMAIL], + password=user_input[CONF_PASSWORD], + ) + try: + await self.cync_auth.login() + except AuthFailedError: + errors["base"] = "invalid_auth" + except TwoFactorRequiredError: + return await self.async_step_two_factor() + except CyncError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return await self._create_config_entry(self.cync_auth.username) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_two_factor( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Attempt login with the two factor auth code sent to the user.""" + errors: dict[str, str] = {} + + if user_input is None: + return self.async_show_form( + step_id="two_factor", data_schema=STEP_TWO_FACTOR_SCHEMA, errors=errors + ) + try: + await self.cync_auth.login(user_input[CONF_TWO_FACTOR_CODE]) + except AuthFailedError: + errors["base"] = "invalid_auth" + except CyncError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return await self._create_config_entry(self.cync_auth.username) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def _create_config_entry(self, user_email: str) -> ConfigFlowResult: + """Create the Cync config entry using input user data.""" + + cync_user = self.cync_auth.user + await self.async_set_unique_id(str(cync_user.user_id)) + self._abort_if_unique_id_configured() + + config = { + CONF_USER_ID: cync_user.user_id, + CONF_AUTHORIZE_STRING: cync_user.authorize, + CONF_EXPIRES_AT: cync_user.expires_at, + CONF_ACCESS_TOKEN: cync_user.access_token, + CONF_REFRESH_TOKEN: cync_user.refresh_token, + } + return self.async_create_entry(title=user_email, data=config) diff --git a/homeassistant/components/cync/const.py b/homeassistant/components/cync/const.py new file mode 100644 index 000000000000..410863b624d1 --- /dev/null +++ b/homeassistant/components/cync/const.py @@ -0,0 +1,9 @@ +"""Constants for the Cync integration.""" + +DOMAIN = "cync" + +CONF_TWO_FACTOR_CODE = "two_factor_code" +CONF_USER_ID = "user_id" +CONF_AUTHORIZE_STRING = "authorize_string" +CONF_EXPIRES_AT = "expires_at" +CONF_REFRESH_TOKEN = "refresh_token" diff --git a/homeassistant/components/cync/coordinator.py b/homeassistant/components/cync/coordinator.py new file mode 100644 index 000000000000..84bfa6d0fee7 --- /dev/null +++ b/homeassistant/components/cync/coordinator.py @@ -0,0 +1,87 @@ +"""Coordinator to handle keeping device states up to date.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +import time + +from pycync import Cync, CyncDevice, User +from pycync.exceptions import AuthFailedError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONF_EXPIRES_AT, CONF_REFRESH_TOKEN + +_LOGGER = logging.getLogger(__name__) + +type CyncConfigEntry = ConfigEntry[CyncCoordinator] + + +class CyncCoordinator(DataUpdateCoordinator[dict[int, CyncDevice]]): + """Coordinator to handle updating Cync device states.""" + + config_entry: CyncConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: CyncConfigEntry, cync: Cync + ) -> None: + """Initialize the Cync coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Cync Data Coordinator", + config_entry=config_entry, + update_interval=timedelta(seconds=30), + always_update=True, + ) + self.cync = cync + + async def on_data_update(self, data: dict[int, CyncDevice]) -> None: + """Update registered devices with new data.""" + merged_data = self.data | data if self.data else data + self.async_set_updated_data(merged_data) + + async def _async_setup(self) -> None: + """Set up the coordinator with initial device states.""" + logged_in_user = self.cync.get_logged_in_user() + if logged_in_user.access_token != self.config_entry.data[CONF_ACCESS_TOKEN]: + await self._update_config_cync_credentials(logged_in_user) + + async def _async_update_data(self) -> dict[int, CyncDevice]: + """First, refresh the user's auth token if it is set to expire in less than one hour. + + Then, fetch all current device states. + """ + + logged_in_user = self.cync.get_logged_in_user() + if logged_in_user.expires_at - time.time() < 3600: + await self._async_refresh_cync_credentials() + + self.cync.update_device_states() + current_device_states = self.cync.get_devices() + + return {device.device_id: device for device in current_device_states} + + async def _async_refresh_cync_credentials(self) -> None: + """Attempt to refresh the Cync user's authentication token.""" + + try: + refreshed_user = await self.cync.refresh_credentials() + except AuthFailedError as ex: + raise ConfigEntryAuthFailed("Unable to refresh user token") from ex + else: + await self._update_config_cync_credentials(refreshed_user) + + async def _update_config_cync_credentials(self, user_info: User) -> None: + """Update the config entry with current user info.""" + + new_data = {**self.config_entry.data} + new_data[CONF_ACCESS_TOKEN] = user_info.access_token + new_data[CONF_REFRESH_TOKEN] = user_info.refresh_token + new_data[CONF_EXPIRES_AT] = user_info.expires_at + self.hass.config_entries.async_update_entry(self.config_entry, data=new_data) diff --git a/homeassistant/components/cync/entity.py b/homeassistant/components/cync/entity.py new file mode 100644 index 000000000000..c2946615e1ce --- /dev/null +++ b/homeassistant/components/cync/entity.py @@ -0,0 +1,45 @@ +"""Setup for a generic entity type for the Cync integration.""" + +from pycync.devices import CyncDevice + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import CyncCoordinator + + +class CyncBaseEntity(CoordinatorEntity[CyncCoordinator]): + """Generic base entity for Cync devices.""" + + _attr_has_entity_name = True + + def __init__( + self, + device: CyncDevice, + coordinator: CyncCoordinator, + room_name: str | None = None, + ) -> None: + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator) + + self._cync_device_id = device.device_id + self._attr_unique_id = device.unique_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.unique_id)}, + manufacturer="GE Lighting", + name=device.name, + suggested_area=room_name, + ) + + @property + def available(self) -> bool: + """Determines whether this device is currently available.""" + + return ( + super().available + and self.coordinator.data is not None + and self._cync_device_id in self.coordinator.data + and self.coordinator.data[self._cync_device_id].is_online + ) diff --git a/homeassistant/components/cync/light.py b/homeassistant/components/cync/light.py new file mode 100644 index 000000000000..8604beab4178 --- /dev/null +++ b/homeassistant/components/cync/light.py @@ -0,0 +1,180 @@ +"""Support for Cync light entities.""" + +from typing import Any + +from pycync import CyncLight +from pycync.devices.capabilities import CyncCapability + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + ColorMode, + LightEntity, + filter_supported_color_modes, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.color import value_to_brightness +from homeassistant.util.scaling import scale_ranged_value_to_int_range + +from .coordinator import CyncConfigEntry, CyncCoordinator +from .entity import CyncBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CyncConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Cync lights from a config entry.""" + + coordinator = entry.runtime_data + cync = coordinator.cync + + entities_to_add = [] + + for home in cync.get_homes(): + for room in home.rooms: + room_lights = [ + CyncLightEntity(device, coordinator, room.name) + for device in room.devices + if isinstance(device, CyncLight) + ] + entities_to_add.extend(room_lights) + + group_lights = [ + CyncLightEntity(device, coordinator, room.name) + for group in room.groups + for device in group.devices + if isinstance(device, CyncLight) + ] + entities_to_add.extend(group_lights) + + async_add_entities(entities_to_add) + + +class CyncLightEntity(CyncBaseEntity, LightEntity): + """Representation of a Cync light.""" + + _attr_color_mode = ColorMode.ONOFF + _attr_min_color_temp_kelvin = 2000 + _attr_max_color_temp_kelvin = 7000 + _attr_translation_key = "light" + _attr_name = None + + BRIGHTNESS_SCALE = (0, 100) + + def __init__( + self, + device: CyncLight, + coordinator: CyncCoordinator, + room_name: str | None = None, + ) -> None: + """Set up base attributes.""" + super().__init__(device, coordinator, room_name) + + supported_color_modes = {ColorMode.ONOFF} + if device.supports_capability(CyncCapability.CCT_COLOR): + supported_color_modes.add(ColorMode.COLOR_TEMP) + if device.supports_capability(CyncCapability.DIMMING): + supported_color_modes.add(ColorMode.BRIGHTNESS) + if device.supports_capability(CyncCapability.RGB_COLOR): + supported_color_modes.add(ColorMode.RGB) + self._attr_supported_color_modes = filter_supported_color_modes( + supported_color_modes + ) + + @property + def is_on(self) -> bool | None: + """Return True if the light is on.""" + return self._device.is_on + + @property + def brightness(self) -> int: + """Provide the light's current brightness.""" + return value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness) + + @property + def color_temp_kelvin(self) -> int: + """Return color temperature in kelvin.""" + return scale_ranged_value_to_int_range( + (1, 100), + (self.min_color_temp_kelvin, self.max_color_temp_kelvin), + self._device.color_temp, + ) + + @property + def rgb_color(self) -> tuple[int, int, int]: + """Provide the light's current color in RGB format.""" + return self._device.rgb + + @property + def color_mode(self) -> str | None: + """Return the active color mode.""" + + if ( + self._device.supports_capability(CyncCapability.CCT_COLOR) + and self._device.color_mode > 0 + and self._device.color_mode <= 100 + ): + return ColorMode.COLOR_TEMP + if ( + self._device.supports_capability(CyncCapability.RGB_COLOR) + and self._device.color_mode == 254 + ): + return ColorMode.RGB + if self._device.supports_capability(CyncCapability.DIMMING): + return ColorMode.BRIGHTNESS + + return ColorMode.ONOFF + + async def async_turn_on(self, **kwargs: Any) -> None: + """Process an action on the light.""" + if not kwargs: + await self._device.turn_on() + + elif kwargs.get(ATTR_COLOR_TEMP_KELVIN) is not None: + color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN) + converted_color_temp = self._normalize_color_temp(color_temp) + + await self._device.set_color_temp(converted_color_temp) + elif kwargs.get(ATTR_RGB_COLOR) is not None: + rgb = kwargs.get(ATTR_RGB_COLOR) + + await self._device.set_rgb(rgb) + elif kwargs.get(ATTR_BRIGHTNESS) is not None: + brightness = kwargs.get(ATTR_BRIGHTNESS) + converted_brightness = self._normalize_brightness(brightness) + + await self._device.set_brightness(converted_brightness) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + await self._device.turn_off() + + def _normalize_brightness(self, brightness: float | None) -> int | None: + """Return calculated brightness value scaled between 0-100.""" + if brightness is not None: + return int((brightness / 255) * 100) + + return None + + def _normalize_color_temp(self, color_temp_kelvin: float | None) -> int | None: + """Return calculated color temp value scaled between 1-100.""" + if color_temp_kelvin is not None: + kelvin_range = self.max_color_temp_kelvin - self.min_color_temp_kelvin + scaled_kelvin = int( + ((color_temp_kelvin - self.min_color_temp_kelvin) / kelvin_range) * 100 + ) + if scaled_kelvin == 0: + scaled_kelvin += 1 + + return scaled_kelvin + return None + + @property + def _device(self) -> CyncLight: + """Fetch the reference to the backing Cync light for this device.""" + + return self.coordinator.data[self._cync_device_id] diff --git a/homeassistant/components/cync/manifest.json b/homeassistant/components/cync/manifest.json new file mode 100644 index 000000000000..d02b6ed1d9b1 --- /dev/null +++ b/homeassistant/components/cync/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "cync", + "name": "Cync", + "codeowners": ["@Kinachi249"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/cync", + "integration_type": "hub", + "iot_class": "cloud_push", + "quality_scale": "bronze", + "requirements": ["pycync==0.4.0"] +} diff --git a/homeassistant/components/cync/quality_scale.yaml b/homeassistant/components/cync/quality_scale.yaml new file mode 100644 index 000000000000..7e106cdd49e6 --- /dev/null +++ b/homeassistant/components/cync/quality_scale.yaml @@ -0,0 +1,69 @@ +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: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/cync/strings.json b/homeassistant/components/cync/strings.json new file mode 100644 index 000000000000..0515c053cfca --- /dev/null +++ b/homeassistant/components/cync/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "Your Cync account's email address", + "password": "Your Cync account's password" + } + }, + "two_factor": { + "data": { + "two_factor_code": "Two-factor code" + }, + "data_description": { + "two_factor_code": "The two-factor code sent to your Cync account's email" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 711c9f793e2e..03b8f57c6eba 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -127,6 +127,7 @@ FLOWS = { "coolmaster", "cpuspeed", "crownstone", + "cync", "daikin", "datadog", "deako", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8ab7e165dcf4..e260b37afe61 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1163,6 +1163,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "cync": { + "name": "Cync", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "dacia": { "name": "Dacia", "integration_type": "virtual", diff --git a/requirements_all.txt b/requirements_all.txt index c92bc0b3d1c3..1f16fc78a345 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1929,6 +1929,9 @@ pycsspeechtts==1.0.8 # homeassistant.components.cups # pycups==2.0.4 +# homeassistant.components.cync +pycync==0.4.0 + # homeassistant.components.daikin pydaikin==2.16.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5264bd7150e4..48ad0d5f077c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1622,6 +1622,9 @@ pycsspeechtts==1.0.8 # homeassistant.components.cups # pycups==2.0.4 +# homeassistant.components.cync +pycync==0.4.0 + # homeassistant.components.daikin pydaikin==2.16.0 diff --git a/tests/components/cync/__init__.py b/tests/components/cync/__init__.py new file mode 100644 index 000000000000..56cab084f998 --- /dev/null +++ b/tests/components/cync/__init__.py @@ -0,0 +1,15 @@ +"""Tests for the Cync integration.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Sets up the Cync integration to be used in testing.""" + 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/cync/conftest.py b/tests/components/cync/conftest.py new file mode 100644 index 000000000000..2ea6e352a75b --- /dev/null +++ b/tests/components/cync/conftest.py @@ -0,0 +1,91 @@ +"""Common fixtures for the Cync tests.""" + +from collections.abc import Generator +import time +from unittest.mock import AsyncMock, patch + +from pycync import Cync, CyncHome +import pytest + +from homeassistant.components.cync.const import ( + CONF_AUTHORIZE_STRING, + CONF_EXPIRES_AT, + CONF_REFRESH_TOKEN, + CONF_USER_ID, + DOMAIN, +) +from homeassistant.const import CONF_ACCESS_TOKEN + +from .const import MOCKED_EMAIL, MOCKED_USER + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture(autouse=True) +def auth_client(): + """Mock a pycync.Auth client.""" + with patch( + "homeassistant.components.cync.config_flow.Auth", autospec=True + ) as sc_class_mock: + client_mock = sc_class_mock.return_value + client_mock.user = MOCKED_USER + client_mock.username = MOCKED_EMAIL + yield client_mock + + +@pytest.fixture(autouse=True) +def cync_client(): + """Mock a pycync.Cync client.""" + with ( + patch( + "homeassistant.components.cync.coordinator.Cync", + spec=Cync, + ) as cync_mock, + patch( + "homeassistant.components.cync.Cync", + new=cync_mock, + ), + ): + cync_mock.get_logged_in_user.return_value = MOCKED_USER + + home_fixture: CyncHome = CyncHome.from_dict( + load_json_object_fixture("home.json", DOMAIN) + ) + cync_mock.get_homes.return_value = [home_fixture] + + available_mock_devices = [ + device + for device in home_fixture.get_flattened_device_list() + if device.is_online + ] + cync_mock.get_devices.return_value = available_mock_devices + + cync_mock.create.return_value = cync_mock + client_mock = cync_mock.return_value + yield client_mock + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.cync.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a Cync config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=MOCKED_EMAIL, + unique_id=str(MOCKED_USER.user_id), + data={ + CONF_USER_ID: MOCKED_USER.user_id, + CONF_AUTHORIZE_STRING: "test_authorize_string", + CONF_EXPIRES_AT: (time.time() * 1000) + 3600000, + CONF_ACCESS_TOKEN: "test_token", + CONF_REFRESH_TOKEN: "test_refresh_token", + }, + ) diff --git a/tests/components/cync/const.py b/tests/components/cync/const.py new file mode 100644 index 000000000000..79f7e8b8b215 --- /dev/null +++ b/tests/components/cync/const.py @@ -0,0 +1,14 @@ +"""Test constants used in Cync tests.""" + +import time + +import pycync + +MOCKED_USER = pycync.User( + "test_token", + "test_refresh_token", + "test_authorize_string", + 123456789, + expires_at=(time.time() * 1000) + 3600000, +) +MOCKED_EMAIL = "test@testuser.com" diff --git a/tests/components/cync/fixtures/home.json b/tests/components/cync/fixtures/home.json new file mode 100644 index 000000000000..22e009de9650 --- /dev/null +++ b/tests/components/cync/fixtures/home.json @@ -0,0 +1,76 @@ +{ + "name": "My Home", + "home_id": 1000, + "rooms": [ + { + "name": "Bedroom", + "room_id": 1100, + "home_id": 1000, + "groups": [], + "devices": [ + { + "name": "Bedroom Lamp", + "is_online": true, + "wifi_connected": true, + "device_id": 1101, + "mesh_device_id": 10001, + "home_id": 1000, + "device_type_id": 137, + "device_type": "LIGHT", + "mac_address": "ABCDEF123456", + "product_id": "product123", + "authorize_code": "abcd_code", + "is_on": true, + "brightness": 80, + "color_temp": 20 + } + ] + }, + { + "name": "Office", + "room_id": 1200, + "home_id": 1000, + "groups": [ + { + "name": "Office Lamp", + "group_id": 1110, + "home_id": 1000, + "devices": [ + { + "name": "Lamp Bulb 1", + "is_online": true, + "wifi_connected": false, + "device_id": 1111, + "mesh_device_id": 10002, + "home_id": 1000, + "device_type_id": 137, + "device_type": "LIGHT", + "mac_address": "654321ABCDEF", + "product_id": "product123", + "authorize_code": "abcd_code", + "is_on": true, + "brightness": 90, + "color_temp": 254, + "rgb": [120, 145, 180] + }, + { + "name": "Lamp Bulb 2", + "is_online": false, + "wifi_connected": false, + "device_id": 1112, + "mesh_device_id": 10003, + "home_id": 1000, + "device_type_id": 137, + "device_type": "LIGHT", + "mac_address": "FEDCBA654321", + "product_id": "product123", + "authorize_code": "abcd_code" + } + ] + } + ], + "devices": [] + } + ], + "global_devices": [] +} diff --git a/tests/components/cync/snapshots/test_light.ambr b/tests/components/cync/snapshots/test_light.ambr new file mode 100644 index 000000000000..fbe56bb1c75f --- /dev/null +++ b/tests/components/cync/snapshots/test_light.ambr @@ -0,0 +1,233 @@ +# serializer version: 1 +# name: test_entities[light.bedroom_lamp-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + '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_lamp', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'cync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': '1000-1101', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.bedroom_lamp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 205, + 'color_mode': , + 'color_temp': 333, + 'color_temp_kelvin': 2999, + 'friendly_name': 'Bedroom Lamp', + 'hs_color': tuple( + 27.827, + 56.922, + ), + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + 'rgb_color': tuple( + 255, + 177, + 110, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.496, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.bedroom_lamp', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[light.lamp_bulb_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + '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.lamp_bulb_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': 'cync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': '1000-1111', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.lamp_bulb_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 230, + 'color_mode': , + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Lamp Bulb 1', + 'hs_color': tuple( + 215.0, + 33.333, + ), + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + 'rgb_color': tuple( + 120, + 145, + 180, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.248, + 0.27, + ), + }), + 'context': , + 'entity_id': 'light.lamp_bulb_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[light.lamp_bulb_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + '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.lamp_bulb_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': 'cync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': '1000-1112', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.lamp_bulb_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lamp Bulb 2', + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.lamp_bulb_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/cync/test_config_flow.py b/tests/components/cync/test_config_flow.py new file mode 100644 index 000000000000..28f0aee09dac --- /dev/null +++ b/tests/components/cync/test_config_flow.py @@ -0,0 +1,260 @@ +"""Test the Cync config flow.""" + +from unittest.mock import ANY, AsyncMock, MagicMock + +from pycync.exceptions import AuthFailedError, CyncError, TwoFactorRequiredError +import pytest + +from homeassistant.components.cync.const import ( + CONF_AUTHORIZE_STRING, + CONF_EXPIRES_AT, + CONF_REFRESH_TOKEN, + CONF_TWO_FACTOR_CODE, + CONF_USER_ID, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import MOCKED_EMAIL, MOCKED_USER + +from tests.common import MockConfigEntry + + +async def test_form_auth_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test that an auth flow without two factor succeeds.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: MOCKED_EMAIL, + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCKED_EMAIL + assert result["data"] == { + CONF_USER_ID: MOCKED_USER.user_id, + CONF_AUTHORIZE_STRING: "test_authorize_string", + CONF_EXPIRES_AT: ANY, + CONF_ACCESS_TOKEN: "test_token", + CONF_REFRESH_TOKEN: "test_refresh_token", + } + assert result["result"].unique_id == str(MOCKED_USER.user_id) + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_two_factor_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock, auth_client: MagicMock +) -> None: + """Test we handle a request for a two factor code.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + auth_client.login.side_effect = TwoFactorRequiredError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: MOCKED_EMAIL, + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "two_factor" + + # Enter two factor code + auth_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TWO_FACTOR_CODE: "123456", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCKED_EMAIL + assert result["data"] == { + CONF_USER_ID: MOCKED_USER.user_id, + CONF_AUTHORIZE_STRING: "test_authorize_string", + CONF_EXPIRES_AT: ANY, + CONF_ACCESS_TOKEN: "test_token", + CONF_REFRESH_TOKEN: "test_refresh_token", + } + assert result["result"].unique_id == str(MOCKED_USER.user_id) + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_unique_id_already_exists( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that setting up a config with a unique ID that already exists fails.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: MOCKED_EMAIL, + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("error_type", "error_string"), + [ + (AuthFailedError, "invalid_auth"), + (CyncError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_two_factor_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + auth_client: MagicMock, + error_type: Exception, + error_string: str, +) -> None: + """Test we handle a request for a two factor code with errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + auth_client.login.side_effect = TwoFactorRequiredError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: MOCKED_EMAIL, + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "two_factor" + + # Enter two factor code + auth_client.login.side_effect = error_type + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TWO_FACTOR_CODE: "123456", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_string} + assert result["step_id"] == "user" + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + auth_client.login.side_effect = TwoFactorRequiredError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: MOCKED_EMAIL, + CONF_PASSWORD: "test-password", + }, + ) + + # Enter two factor code + auth_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TWO_FACTOR_CODE: "567890", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCKED_EMAIL + assert result["data"] == { + CONF_USER_ID: MOCKED_USER.user_id, + CONF_AUTHORIZE_STRING: "test_authorize_string", + CONF_EXPIRES_AT: ANY, + CONF_ACCESS_TOKEN: "test_token", + CONF_REFRESH_TOKEN: "test_refresh_token", + } + assert result["result"].unique_id == str(MOCKED_USER.user_id) + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error_type", "error_string"), + [ + (AuthFailedError, "invalid_auth"), + (CyncError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + auth_client: MagicMock, + error_type: Exception, + error_string: str, +) -> None: + """Test we handle errors in the user step of the setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + auth_client.login.side_effect = error_type + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: MOCKED_EMAIL, + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_string} + assert result["step_id"] == "user" + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + auth_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: MOCKED_EMAIL, + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCKED_EMAIL + assert result["data"] == { + CONF_USER_ID: MOCKED_USER.user_id, + CONF_AUTHORIZE_STRING: "test_authorize_string", + CONF_EXPIRES_AT: ANY, + CONF_ACCESS_TOKEN: "test_token", + CONF_REFRESH_TOKEN: "test_refresh_token", + } + assert result["result"].unique_id == str(MOCKED_USER.user_id) + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/cync/test_light.py b/tests/components/cync/test_light.py new file mode 100644 index 000000000000..b5563949f45a --- /dev/null +++ b/tests/components/cync/test_light.py @@ -0,0 +1,23 @@ +"""Tests for the Cync integration light platform.""" + +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_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test that light attributes are properly set on setup.""" + + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From ed5f5d4b335de4204721e03460f41bbc713cfc13 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 24 Sep 2025 18:57:56 +0200 Subject: [PATCH 133/189] Add dynamic devices management for Comelit SimpleHome (#152137) --- .../components/comelit/binary_sensor.py | 21 +++- homeassistant/components/comelit/cover.py | 19 +++- homeassistant/components/comelit/light.py | 19 +++- .../components/comelit/quality_scale.yaml | 4 +- homeassistant/components/comelit/sensor.py | 52 ++++++--- homeassistant/components/comelit/switch.py | 19 ++++ tests/components/comelit/test_cover.py | 50 +++++++++ tests/components/comelit/test_light.py | 56 +++++++++- tests/components/comelit/test_sensor.py | 101 +++++++++++++++++- tests/components/comelit/test_switch.py | 56 +++++++++- 10 files changed, 360 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/comelit/binary_sensor.py b/homeassistant/components/comelit/binary_sensor.py index e1be330afae5..68390642c877 100644 --- a/homeassistant/components/comelit/binary_sensor.py +++ b/homeassistant/components/comelit/binary_sensor.py @@ -29,10 +29,23 @@ async def async_setup_entry( coordinator = cast(ComelitVedoSystem, config_entry.runtime_data) - async_add_entities( - ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id) - for device in coordinator.data["alarm_zones"].values() - ) + known_devices: set[int] = set() + + def _check_device() -> None: + current_devices = set(coordinator.data["alarm_zones"]) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + ComelitVedoBinarySensorEntity( + coordinator, device, config_entry.entry_id + ) + for device in coordinator.data["alarm_zones"].values() + if device.index in new_devices + ) + + _check_device() + config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) class ComelitVedoBinarySensorEntity( diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 691ebaec638c..70525ffe7123 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -29,10 +29,21 @@ async def async_setup_entry( coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) - async_add_entities( - ComelitCoverEntity(coordinator, device, config_entry.entry_id) - for device in coordinator.data[COVER].values() - ) + known_devices: set[int] = set() + + def _check_device() -> None: + current_devices = set(coordinator.data[COVER]) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + ComelitCoverEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data[COVER].values() + if device.index in new_devices + ) + + _check_device() + config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity): diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index c04b88c78197..8ff626ed9166 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -27,10 +27,21 @@ async def async_setup_entry( coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) - async_add_entities( - ComelitLightEntity(coordinator, device, config_entry.entry_id) - for device in coordinator.data[LIGHT].values() - ) + known_devices: set[int] = set() + + def _check_device() -> None: + current_devices = set(coordinator.data[LIGHT]) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + ComelitLightEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data[LIGHT].values() + if device.index in new_devices + ) + + _check_device() + config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity): diff --git a/homeassistant/components/comelit/quality_scale.yaml b/homeassistant/components/comelit/quality_scale.yaml index 3d512e713516..21c54e00679e 100644 --- a/homeassistant/components/comelit/quality_scale.yaml +++ b/homeassistant/components/comelit/quality_scale.yaml @@ -57,9 +57,7 @@ rules: docs-supported-functions: done docs-troubleshooting: done docs-use-cases: done - dynamic-devices: - status: todo - comment: missing implementation + dynamic-devices: done entity-category: status: exempt comment: no config or diagnostic entities diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py index a11cac4e1c0a..f47a88723687 100644 --- a/homeassistant/components/comelit/sensor.py +++ b/homeassistant/components/comelit/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Final, cast -from aiocomelit import ComelitSerialBridgeObject, ComelitVedoZoneObject +from aiocomelit.api import ComelitSerialBridgeObject, ComelitVedoZoneObject from aiocomelit.const import BRIDGE, OTHER, AlarmZoneState from homeassistant.components.sensor import ( @@ -65,15 +65,24 @@ async def async_setup_bridge_entry( coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) - entities: list[ComelitBridgeSensorEntity] = [] - for device in coordinator.data[OTHER].values(): - entities.extend( - ComelitBridgeSensorEntity( - coordinator, device, config_entry.entry_id, sensor_desc + known_devices: set[int] = set() + + def _check_device() -> None: + current_devices = set(coordinator.data[OTHER]) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + ComelitBridgeSensorEntity( + coordinator, device, config_entry.entry_id, sensor_desc + ) + for sensor_desc in SENSOR_BRIDGE_TYPES + for device in coordinator.data[OTHER].values() + if device.index in new_devices ) - for sensor_desc in SENSOR_BRIDGE_TYPES - ) - async_add_entities(entities) + + _check_device() + config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) async def async_setup_vedo_entry( @@ -85,15 +94,24 @@ async def async_setup_vedo_entry( coordinator = cast(ComelitVedoSystem, config_entry.runtime_data) - entities: list[ComelitVedoSensorEntity] = [] - for device in coordinator.data["alarm_zones"].values(): - entities.extend( - ComelitVedoSensorEntity( - coordinator, device, config_entry.entry_id, sensor_desc + known_devices: set[int] = set() + + def _check_device() -> None: + current_devices = set(coordinator.data["alarm_zones"]) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + ComelitVedoSensorEntity( + coordinator, device, config_entry.entry_id, sensor_desc + ) + for sensor_desc in SENSOR_VEDO_TYPES + for device in coordinator.data["alarm_zones"].values() + if device.index in new_devices ) - for sensor_desc in SENSOR_VEDO_TYPES - ) - async_add_entities(entities) + + _check_device() + config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity): diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index 1896071596fe..076b6091a3dc 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -39,6 +39,25 @@ async def async_setup_entry( ) async_add_entities(entities) + known_devices: dict[str, set[int]] = { + dev_type: set() for dev_type in (IRRIGATION, OTHER) + } + + def _check_device() -> None: + for dev_type in (IRRIGATION, OTHER): + current_devices = set(coordinator.data[dev_type]) + new_devices = current_devices - known_devices[dev_type] + if new_devices: + known_devices[dev_type].update(new_devices) + async_add_entities( + ComelitSwitchEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data[dev_type].values() + if device.index in new_devices + ) + + _check_device() + config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) + class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity): """Switch device.""" diff --git a/tests/components/comelit/test_cover.py b/tests/components/comelit/test_cover.py index 5513f3c4e256..02efff1dd94a 100644 --- a/tests/components/comelit/test_cover.py +++ b/tests/components/comelit/test_cover.py @@ -193,3 +193,53 @@ async def test_cover_restore_state( assert (state := hass.states.get(ENTITY_ID)) assert state.state == STATE_OPENING + + +async def test_cover_dynamic( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test cover dynamically added.""" + + mock_serial_bridge.reset_mock() + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert hass.states.get(ENTITY_ID) + + entity_id_2 = "cover.cover1" + + mock_serial_bridge.get_all_devices.return_value[COVER] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Cover0", + status=0, + human_status="stopped", + type="cover", + val=0, + protected=0, + zone="Open space", + power=0.0, + power_unit=WATT, + ), + 1: ComelitSerialBridgeObject( + index=1, + name="Cover1", + status=0, + human_status="stopped", + type="cover", + val=0, + protected=0, + zone="Open space", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_ID) + assert hass.states.get(entity_id_2) diff --git a/tests/components/comelit/test_light.py b/tests/components/comelit/test_light.py index 36a191c9ee3d..af2ff22a380e 100644 --- a/tests/components/comelit/test_light.py +++ b/tests/components/comelit/test_light.py @@ -2,9 +2,13 @@ from unittest.mock import AsyncMock, patch +from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.const import LIGHT, WATT +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.comelit.const import SCAN_INTERVAL from homeassistant.components.light import ( DOMAIN as LIGHT_DOMAIN, SERVICE_TOGGLE, @@ -17,7 +21,7 @@ from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform ENTITY_ID = "light.light0" @@ -74,3 +78,53 @@ async def test_light_set_state( assert (state := hass.states.get(ENTITY_ID)) assert state.state == status + + +async def test_light_dynamic( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test light dynamically added.""" + + mock_serial_bridge.reset_mock() + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert hass.states.get(ENTITY_ID) + + entity_id_2 = "light.light1" + + mock_serial_bridge.get_all_devices.return_value[LIGHT] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Light0", + status=0, + human_status="stopped", + type="light", + val=0, + protected=0, + zone="Open space", + power=0.0, + power_unit=WATT, + ), + 1: ComelitSerialBridgeObject( + index=1, + name="Light1", + status=0, + human_status="stopped", + type="light", + val=0, + protected=0, + zone="Open space", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_ID) + assert hass.states.get(entity_id_2) diff --git a/tests/components/comelit/test_sensor.py b/tests/components/comelit/test_sensor.py index 1bf717ca894f..eb9adc0d81ed 100644 --- a/tests/components/comelit/test_sensor.py +++ b/tests/components/comelit/test_sensor.py @@ -2,8 +2,13 @@ from unittest.mock import AsyncMock, patch -from aiocomelit.api import AlarmDataObject, ComelitVedoAreaObject, ComelitVedoZoneObject -from aiocomelit.const import AlarmAreaState, AlarmZoneState +from aiocomelit.api import ( + AlarmDataObject, + ComelitSerialBridgeObject, + ComelitVedoAreaObject, + ComelitVedoZoneObject, +) +from aiocomelit.const import OTHER, WATT, AlarmAreaState, AlarmZoneState from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion @@ -44,7 +49,7 @@ async def test_sensor_state_unknown( mock_vedo: AsyncMock, mock_vedo_config_entry: MockConfigEntry, ) -> None: - """Test sensor unknown state.""" + """Test VEDO sensor unknown state.""" await setup_integration(hass, mock_vedo_config_entry) @@ -88,3 +93,93 @@ async def test_sensor_state_unknown( assert (state := hass.states.get(ENTITY_ID)) assert state.state == STATE_UNKNOWN + + +async def test_serial_bridge_sensor_dynamic( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test Serial Bridge sensor dynamically added.""" + + mock_serial_bridge.reset_mock() + await setup_integration(hass, mock_serial_bridge_config_entry) + + entity_id = "sensor.switch0" + entity_id_2 = "sensor.switch1" + assert hass.states.get(entity_id) + + mock_serial_bridge.get_all_devices.return_value[OTHER] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Switch0", + status=0, + human_status="off", + type="other", + val=0, + protected=0, + zone="Bathroom", + power=0.0, + power_unit=WATT, + ), + 1: ComelitSerialBridgeObject( + index=1, + name="Switch1", + status=0, + human_status="off", + type="other", + val=0, + protected=0, + zone="Bathroom", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) + assert hass.states.get(entity_id_2) + + +async def test_vedo_sensor_dynamic( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, +) -> None: + """Test VEDO sensor dynamically added.""" + + mock_vedo.reset_mock() + await setup_integration(hass, mock_vedo_config_entry) + + assert hass.states.get(ENTITY_ID) + + entity_id_2 = "sensor.zone1" + + mock_vedo.get_all_areas_and_zones.return_value["alarm_zones"] = { + 0: ComelitVedoZoneObject( + index=0, + name="Zone0", + status_api="0x000", + status=0, + human_status=AlarmZoneState.REST, + ), + 1: ComelitVedoZoneObject( + index=1, + name="Zone1", + status_api="0x000", + status=0, + human_status=AlarmZoneState.REST, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_ID) + assert hass.states.get(entity_id_2) diff --git a/tests/components/comelit/test_switch.py b/tests/components/comelit/test_switch.py index 31a4c4b144c8..38955bfad40e 100644 --- a/tests/components/comelit/test_switch.py +++ b/tests/components/comelit/test_switch.py @@ -2,9 +2,13 @@ from unittest.mock import AsyncMock, patch +from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.const import IRRIGATION, WATT +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.comelit.const import SCAN_INTERVAL from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TOGGLE, @@ -17,7 +21,7 @@ from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform ENTITY_ID = "switch.switch0" @@ -74,3 +78,53 @@ async def test_switch_set_state( assert (state := hass.states.get(ENTITY_ID)) assert state.state == status + + +async def test_switch_dynamic( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test switch dynamically added.""" + + mock_serial_bridge.reset_mock() + await setup_integration(hass, mock_serial_bridge_config_entry) + + entity_id = "switch.switch0" + entity_id_2 = "switch.switch1" + assert hass.states.get(entity_id) + + mock_serial_bridge.get_all_devices.return_value[IRRIGATION] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Switch0", + status=0, + human_status="off", + type="irrigation", + val=0, + protected=0, + zone="Terrace", + power=0.0, + power_unit=WATT, + ), + 1: ComelitSerialBridgeObject( + index=1, + name="Switch1", + status=0, + human_status="off", + type="irrigation", + val=0, + protected=0, + zone="Terrace", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) + assert hass.states.get(entity_id_2) From 14d42e43bfe85483f672c9735b1398439b2b6cf4 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 24 Sep 2025 19:00:35 +0200 Subject: [PATCH 134/189] Add dynamic devices management for Alexa Devices (#151975) --- .../components/alexa_devices/binary_sensor.py | 24 ++++++++--- .../components/alexa_devices/notify.py | 24 +++++++---- .../alexa_devices/quality_scale.yaml | 2 +- .../components/alexa_devices/sensor.py | 22 +++++++--- .../components/alexa_devices/switch.py | 22 +++++++--- tests/components/alexa_devices/conftest.py | 4 +- tests/components/alexa_devices/const.py | 35 +++++++++++++--- .../alexa_devices/test_binary_sensor.py | 42 +++++++++++++++++-- .../alexa_devices/test_coordinator.py | 29 ++----------- .../alexa_devices/test_diagnostics.py | 6 +-- tests/components/alexa_devices/test_init.py | 4 +- tests/components/alexa_devices/test_notify.py | 6 +-- tests/components/alexa_devices/test_sensor.py | 8 ++-- .../components/alexa_devices/test_services.py | 12 +++--- tests/components/alexa_devices/test_switch.py | 10 ++--- 15 files changed, 164 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/alexa_devices/binary_sensor.py b/homeassistant/components/alexa_devices/binary_sensor.py index 231f144dd894..410ea4555e24 100644 --- a/homeassistant/components/alexa_devices/binary_sensor.py +++ b/homeassistant/components/alexa_devices/binary_sensor.py @@ -94,12 +94,24 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc) - for sensor_desc in BINARY_SENSORS - for serial_num in coordinator.data - if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key) - ) + known_devices: set[str] = set() + + def _check_device() -> None: + current_devices = set(coordinator.data) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc) + for sensor_desc in BINARY_SENSORS + for serial_num in new_devices + if sensor_desc.is_supported( + coordinator.data[serial_num], sensor_desc.key + ) + ) + + _check_device() + entry.async_on_unload(coordinator.async_add_listener(_check_device)) class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity): diff --git a/homeassistant/components/alexa_devices/notify.py b/homeassistant/components/alexa_devices/notify.py index 08f2e214f38c..d046b580cb7f 100644 --- a/homeassistant/components/alexa_devices/notify.py +++ b/homeassistant/components/alexa_devices/notify.py @@ -57,13 +57,23 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - AmazonNotifyEntity(coordinator, serial_num, sensor_desc) - for sensor_desc in NOTIFY - for serial_num in coordinator.data - if sensor_desc.subkey in coordinator.data[serial_num].capabilities - and sensor_desc.is_supported(coordinator.data[serial_num]) - ) + known_devices: set[str] = set() + + def _check_device() -> None: + current_devices = set(coordinator.data) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + AmazonNotifyEntity(coordinator, serial_num, sensor_desc) + for sensor_desc in NOTIFY + for serial_num in new_devices + if sensor_desc.subkey in coordinator.data[serial_num].capabilities + and sensor_desc.is_supported(coordinator.data[serial_num]) + ) + + _check_device() + entry.async_on_unload(coordinator.async_add_listener(_check_device)) class AmazonNotifyEntity(AmazonEntity, NotifyEntity): diff --git a/homeassistant/components/alexa_devices/quality_scale.yaml b/homeassistant/components/alexa_devices/quality_scale.yaml index da48f366a6c2..0933f1783599 100644 --- a/homeassistant/components/alexa_devices/quality_scale.yaml +++ b/homeassistant/components/alexa_devices/quality_scale.yaml @@ -53,7 +53,7 @@ rules: docs-supported-functions: done docs-troubleshooting: done docs-use-cases: done - dynamic-devices: todo + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done diff --git a/homeassistant/components/alexa_devices/sensor.py b/homeassistant/components/alexa_devices/sensor.py index 738e0ac2de57..1a863e87c1a7 100644 --- a/homeassistant/components/alexa_devices/sensor.py +++ b/homeassistant/components/alexa_devices/sensor.py @@ -62,12 +62,22 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - AmazonSensorEntity(coordinator, serial_num, sensor_desc) - for sensor_desc in SENSORS - for serial_num in coordinator.data - if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None - ) + known_devices: set[str] = set() + + def _check_device() -> None: + current_devices = set(coordinator.data) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + AmazonSensorEntity(coordinator, serial_num, sensor_desc) + for sensor_desc in SENSORS + for serial_num in new_devices + if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None + ) + + _check_device() + entry.async_on_unload(coordinator.async_add_listener(_check_device)) class AmazonSensorEntity(AmazonEntity, SensorEntity): diff --git a/homeassistant/components/alexa_devices/switch.py b/homeassistant/components/alexa_devices/switch.py index e53ea40965a8..138013666c6e 100644 --- a/homeassistant/components/alexa_devices/switch.py +++ b/homeassistant/components/alexa_devices/switch.py @@ -48,12 +48,22 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - AmazonSwitchEntity(coordinator, serial_num, switch_desc) - for switch_desc in SWITCHES - for serial_num in coordinator.data - if switch_desc.subkey in coordinator.data[serial_num].capabilities - ) + known_devices: set[str] = set() + + def _check_device() -> None: + current_devices = set(coordinator.data) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + AmazonSwitchEntity(coordinator, serial_num, switch_desc) + for switch_desc in SWITCHES + for serial_num in new_devices + if switch_desc.subkey in coordinator.data[serial_num].capabilities + ) + + _check_device() + entry.async_on_unload(coordinator.async_add_listener(_check_device)) class AmazonSwitchEntity(AmazonEntity, SwitchEntity): diff --git a/tests/components/alexa_devices/conftest.py b/tests/components/alexa_devices/conftest.py index d9864fdeb313..bed7abc3e336 100644 --- a/tests/components/alexa_devices/conftest.py +++ b/tests/components/alexa_devices/conftest.py @@ -14,7 +14,7 @@ from homeassistant.components.alexa_devices.const import ( ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from .const import TEST_DEVICE, TEST_PASSWORD, TEST_SERIAL_NUMBER, TEST_USERNAME +from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_PASSWORD, TEST_USERNAME from tests.common import MockConfigEntry @@ -48,7 +48,7 @@ def mock_amazon_devices_client() -> Generator[AsyncMock]: CONF_SITE: "https://www.amazon.com", } client.get_devices_data.return_value = { - TEST_SERIAL_NUMBER: deepcopy(TEST_DEVICE) + TEST_DEVICE_1_SN: deepcopy(TEST_DEVICE_1) } client.get_model_details = lambda device: DEVICE_TYPE_TO_MODEL.get( device.device_type diff --git a/tests/components/alexa_devices/const.py b/tests/components/alexa_devices/const.py index fa30226849ee..d078e92199ed 100644 --- a/tests/components/alexa_devices/const.py +++ b/tests/components/alexa_devices/const.py @@ -4,20 +4,19 @@ from aioamazondevices.api import AmazonDevice, AmazonDeviceSensor TEST_CODE = "023123" TEST_PASSWORD = "fake_password" -TEST_SERIAL_NUMBER = "echo_test_serial_number" TEST_USERNAME = "fake_email@gmail.com" -TEST_DEVICE_ID = "echo_test_device_id" - -TEST_DEVICE = AmazonDevice( +TEST_DEVICE_1_SN = "echo_test_serial_number" +TEST_DEVICE_1_ID = "echo_test_device_id" +TEST_DEVICE_1 = AmazonDevice( account_name="Echo Test", capabilities=["AUDIO_PLAYER", "MICROPHONE"], device_family="mine", device_type="echo", device_owner_customer_id="amazon_ower_id", - device_cluster_members=[TEST_SERIAL_NUMBER], + device_cluster_members=[TEST_DEVICE_1_SN], online=True, - serial_number=TEST_SERIAL_NUMBER, + serial_number=TEST_DEVICE_1_SN, software_version="echo_test_software_version", do_not_disturb=False, response_style=None, @@ -30,3 +29,27 @@ TEST_DEVICE = AmazonDevice( ) }, ) + +TEST_DEVICE_2_SN = "echo_test_2_serial_number" +TEST_DEVICE_2_ID = "echo_test_2_device_id" +TEST_DEVICE_2 = AmazonDevice( + account_name="Echo Test 2", + capabilities=["AUDIO_PLAYER", "MICROPHONE"], + device_family="mine", + device_type="echo", + device_owner_customer_id="amazon_ower_id", + device_cluster_members=[TEST_DEVICE_2_SN], + online=True, + serial_number=TEST_DEVICE_2_SN, + software_version="echo_test_2_software_version", + do_not_disturb=False, + response_style=None, + bluetooth_state=True, + entity_id="11111111-2222-3333-4444-555555555555", + appliance_id="G1234567890123456789012345678A", + sensors={ + "temperature": AmazonDeviceSensor( + name="temperature", value="22.5", scale="CELSIUS" + ) + }, +) diff --git a/tests/components/alexa_devices/test_binary_sensor.py b/tests/components/alexa_devices/test_binary_sensor.py index a2e38b3459b1..bcb89664da46 100644 --- a/tests/components/alexa_devices/test_binary_sensor.py +++ b/tests/components/alexa_devices/test_binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import setup_integration -from .const import TEST_SERIAL_NUMBER +from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_DEVICE_2, TEST_DEVICE_2_SN from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -83,7 +83,7 @@ async def test_offline_device( entity_id = "binary_sensor.echo_test_connectivity" mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = False await setup_integration(hass, mock_config_entry) @@ -92,7 +92,7 @@ async def test_offline_device( assert state.state == STATE_UNAVAILABLE mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = True freezer.tick(SCAN_INTERVAL) @@ -101,3 +101,39 @@ async def test_offline_device( assert (state := hass.states.get(entity_id)) assert state.state != STATE_UNAVAILABLE + + +async def test_dynamic_device( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test device added dynamically.""" + + entity_id_1 = "binary_sensor.echo_test_connectivity" + entity_id_2 = "binary_sensor.echo_test_2_connectivity" + + mock_amazon_devices_client.get_devices_data.return_value = { + TEST_DEVICE_1_SN: TEST_DEVICE_1, + } + + await setup_integration(hass, mock_config_entry) + + assert (state := hass.states.get(entity_id_1)) + assert state.state == STATE_ON + + mock_amazon_devices_client.get_devices_data.return_value = { + TEST_DEVICE_1_SN: TEST_DEVICE_1, + TEST_DEVICE_2_SN: TEST_DEVICE_2, + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id_1)) + assert state.state == STATE_ON + + assert (state := hass.states.get(entity_id_2)) + assert state.state == STATE_ON diff --git a/tests/components/alexa_devices/test_coordinator.py b/tests/components/alexa_devices/test_coordinator.py index 3768404f8717..3e0880fcd078 100644 --- a/tests/components/alexa_devices/test_coordinator.py +++ b/tests/components/alexa_devices/test_coordinator.py @@ -2,7 +2,6 @@ from unittest.mock import AsyncMock -from aioamazondevices.api import AmazonDevice, AmazonDeviceSensor from freezegun.api import FrozenDateTimeFactory from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL @@ -10,7 +9,7 @@ from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from . import setup_integration -from .const import TEST_DEVICE, TEST_SERIAL_NUMBER +from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_DEVICE_2, TEST_DEVICE_2_SN from tests.common import MockConfigEntry, async_fire_time_changed @@ -27,28 +26,8 @@ async def test_coordinator_stale_device( entity_id_1 = "binary_sensor.echo_test_2_connectivity" mock_amazon_devices_client.get_devices_data.return_value = { - TEST_SERIAL_NUMBER: TEST_DEVICE, - "echo_test_2_serial_number_2": AmazonDevice( - account_name="Echo Test 2", - capabilities=["AUDIO_PLAYER", "MICROPHONE"], - device_family="mine", - device_type="echo", - device_owner_customer_id="amazon_ower_id", - device_cluster_members=["echo_test_2_serial_number_2"], - online=True, - serial_number="echo_test_2_serial_number_2", - software_version="echo_test_2_software_version", - do_not_disturb=False, - response_style=None, - bluetooth_state=True, - entity_id="11111111-2222-3333-4444-555555555555", - appliance_id="G1234567890123456789012345678A", - sensors={ - "temperature": AmazonDeviceSensor( - name="temperature", value="22.5", scale="CELSIUS" - ) - }, - ), + TEST_DEVICE_1_SN: TEST_DEVICE_1, + TEST_DEVICE_2_SN: TEST_DEVICE_2, } await setup_integration(hass, mock_config_entry) @@ -59,7 +38,7 @@ async def test_coordinator_stale_device( assert state.state == STATE_ON mock_amazon_devices_client.get_devices_data.return_value = { - TEST_SERIAL_NUMBER: TEST_DEVICE, + TEST_DEVICE_1_SN: TEST_DEVICE_1, } freezer.tick(SCAN_INTERVAL) diff --git a/tests/components/alexa_devices/test_diagnostics.py b/tests/components/alexa_devices/test_diagnostics.py index 3c18d4325438..6c7a6ef4a81e 100644 --- a/tests/components/alexa_devices/test_diagnostics.py +++ b/tests/components/alexa_devices/test_diagnostics.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from . import setup_integration -from .const import TEST_SERIAL_NUMBER +from .const import TEST_DEVICE_1_SN from tests.common import MockConfigEntry from tests.components.diagnostics import ( @@ -54,9 +54,7 @@ async def test_device_diagnostics( """Test Amazon device diagnostics.""" await setup_integration(hass, mock_config_entry) - device = device_registry.async_get_device( - identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} - ) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_DEVICE_1_SN)}) assert device, repr(device_registry.devices) assert await get_diagnostics_for_device( diff --git a/tests/components/alexa_devices/test_init.py b/tests/components/alexa_devices/test_init.py index 328654682e91..0b20b1fe239e 100644 --- a/tests/components/alexa_devices/test_init.py +++ b/tests/components/alexa_devices/test_init.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from . import setup_integration -from .const import TEST_PASSWORD, TEST_SERIAL_NUMBER, TEST_USERNAME +from .const import TEST_DEVICE_1_SN, TEST_PASSWORD, TEST_USERNAME from tests.common import MockConfigEntry @@ -31,7 +31,7 @@ async def test_device_info( """Test device registry integration.""" await setup_integration(hass, mock_config_entry) device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + identifiers={(DOMAIN, TEST_DEVICE_1_SN)} ) assert device_entry is not None assert device_entry == snapshot diff --git a/tests/components/alexa_devices/test_notify.py b/tests/components/alexa_devices/test_notify.py index 6067874e3706..eafea4b525c3 100644 --- a/tests/components/alexa_devices/test_notify.py +++ b/tests/components/alexa_devices/test_notify.py @@ -18,7 +18,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import setup_integration -from .const import TEST_SERIAL_NUMBER +from .const import TEST_DEVICE_1_SN from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -83,7 +83,7 @@ async def test_offline_device( entity_id = "notify.echo_test_announce" mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = False await setup_integration(hass, mock_config_entry) @@ -92,7 +92,7 @@ async def test_offline_device( assert state.state == STATE_UNAVAILABLE mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = True freezer.tick(SCAN_INTERVAL) diff --git a/tests/components/alexa_devices/test_sensor.py b/tests/components/alexa_devices/test_sensor.py index e8875fe08a44..560a7e10b90d 100644 --- a/tests/components/alexa_devices/test_sensor.py +++ b/tests/components/alexa_devices/test_sensor.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import setup_integration -from .const import TEST_SERIAL_NUMBER +from .const import TEST_DEVICE_1_SN from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -83,7 +83,7 @@ async def test_offline_device( entity_id = "sensor.echo_test_temperature" mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = False await setup_integration(hass, mock_config_entry) @@ -92,7 +92,7 @@ async def test_offline_device( assert state.state == STATE_UNAVAILABLE mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = True freezer.tick(SCAN_INTERVAL) @@ -133,7 +133,7 @@ async def test_unit_of_measurement( entity_id = f"sensor.echo_test_{sensor}" mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].sensors = {sensor: AmazonDeviceSensor(name=sensor, value=api_value, scale=scale)} await setup_integration(hass, mock_config_entry) diff --git a/tests/components/alexa_devices/test_services.py b/tests/components/alexa_devices/test_services.py index 72cef62a9662..9ea1a271a7f0 100644 --- a/tests/components/alexa_devices/test_services.py +++ b/tests/components/alexa_devices/test_services.py @@ -19,7 +19,7 @@ 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 .const import TEST_DEVICE_1_ID, TEST_DEVICE_1_SN from tests.common import MockConfigEntry, mock_device_registry @@ -49,7 +49,7 @@ async def test_send_sound_service( await setup_integration(hass, mock_config_entry) device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + identifiers={(DOMAIN, TEST_DEVICE_1_SN)} ) assert device_entry @@ -79,7 +79,7 @@ async def test_send_text_service( await setup_integration(hass, mock_config_entry) device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + identifiers={(DOMAIN, TEST_DEVICE_1_SN)} ) assert device_entry @@ -108,7 +108,7 @@ async def test_send_text_service( ), ( "wrong_sound_name", - TEST_DEVICE_ID, + TEST_DEVICE_1_ID, "invalid_sound_value", { "sound": "wrong_sound_name", @@ -128,7 +128,7 @@ async def test_invalid_parameters( """Test invalid service parameters.""" device_entry = dr.DeviceEntry( - id=TEST_DEVICE_ID, identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + id=TEST_DEVICE_1_ID, identifiers={(DOMAIN, TEST_DEVICE_1_SN)} ) mock_device_registry( hass, @@ -164,7 +164,7 @@ async def test_config_entry_not_loaded( await setup_integration(hass, mock_config_entry) device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + identifiers={(DOMAIN, TEST_DEVICE_1_SN)} ) assert device_entry diff --git a/tests/components/alexa_devices/test_switch.py b/tests/components/alexa_devices/test_switch.py index 26a18fb731a7..c5039d68da25 100644 --- a/tests/components/alexa_devices/test_switch.py +++ b/tests/components/alexa_devices/test_switch.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import setup_integration -from .conftest import TEST_SERIAL_NUMBER +from .conftest import TEST_DEVICE_1_SN from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -67,7 +67,7 @@ async def test_switch_dnd( assert mock_amazon_devices_client.set_do_not_disturb.call_count == 1 mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].do_not_disturb = True freezer.tick(SCAN_INTERVAL) @@ -85,7 +85,7 @@ async def test_switch_dnd( ) mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].do_not_disturb = False freezer.tick(SCAN_INTERVAL) @@ -108,7 +108,7 @@ async def test_offline_device( entity_id = "switch.echo_test_do_not_disturb" mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = False await setup_integration(hass, mock_config_entry) @@ -117,7 +117,7 @@ async def test_offline_device( assert state.state == STATE_UNAVAILABLE mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = True freezer.tick(SCAN_INTERVAL) From 9cc78680d6a10cfdc42d3a25d12231dcc665d2f4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 24 Sep 2025 20:28:49 +0200 Subject: [PATCH 135/189] Fix lg_thinq test RuntimeWarning (#152910) --- tests/components/lg_thinq/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/lg_thinq/conftest.py b/tests/components/lg_thinq/conftest.py index c762d906568c..a0626ddb603f 100644 --- a/tests/components/lg_thinq/conftest.py +++ b/tests/components/lg_thinq/conftest.py @@ -148,4 +148,5 @@ def devices(mock_thinq_api: AsyncMock, device_fixture: str) -> Generator[AsyncMo mock_thinq_api.async_get_device_energy_profile.return_value = ( load_json_object_fixture(f"{device_fixture}/energy_profile.json", DOMAIN) ) + mock_thinq_api.async_get_route.return_value = MagicMock() return mock_thinq_api From ddecf1ac215a837b5fad6acab9384e7f387c5193 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Sep 2025 16:00:45 -0500 Subject: [PATCH 136/189] Bump aioesphomeapi to 41.9.3 to fix segfault (#152912) --- 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 4835ead20494..39ff0bc184c8 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==41.9.0", + "aioesphomeapi==41.9.3", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.3.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 1f16fc78a345..7d3421674a52 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.9.0 +aioesphomeapi==41.9.3 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 48ad0d5f077c..bec1bb02bf22 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.9.0 +aioesphomeapi==41.9.3 # homeassistant.components.flo aioflo==2021.11.0 From 95e7b009963b3012257d99e08d8e120e72af9d10 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 24 Sep 2025 23:03:31 +0200 Subject: [PATCH 137/189] Update IQS to platinum for Comelit SimpleHome (#152906) --- homeassistant/components/comelit/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 44101f0fd06c..4e8fee1bba63 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["aiocomelit==0.12.3"] } From 076e51017bd883a366a5703b6c87cd10de6cdbd5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 24 Sep 2025 23:12:20 +0200 Subject: [PATCH 138/189] Bump to home-assistant/wheels@2025.09.0 (#152920) --- .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 4aa9724f5152..984d1e91c8a2 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -160,7 +160,7 @@ jobs: # home-assistant/wheels doesn't support sha pinning - name: Build wheels - uses: home-assistant/wheels@2025.07.0 + uses: home-assistant/wheels@2025.09.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -221,7 +221,7 @@ jobs: # home-assistant/wheels doesn't support sha pinning - name: Build wheels - uses: home-assistant/wheels@2025.07.0 + uses: home-assistant/wheels@2025.09.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 From 09750872b5fa92d07580f8c0de13b413a4670da2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 24 Sep 2025 23:55:32 +0200 Subject: [PATCH 139/189] Bump version to 2025.11.0dev0 (#152915) --- .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 77c5d02bc56a..3cad6a4e5324 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,7 @@ env: CACHE_VERSION: 8 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1 - HA_SHORT_VERSION: "2025.10" + HA_SHORT_VERSION: "2025.11" 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 3b9702b972ee..02daeadf0112 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 = 10 +MINOR_VERSION: Final = 11 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 366482ec7fc3..ae1d8fa5c10d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.10.0.dev0" +version = "2025.11.0.dev0" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From ae7bc7fb1b19944754a752c613d55dc6c131370a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Sep 2025 19:16:48 -0500 Subject: [PATCH 140/189] Bump aioesphomeapi to 41.9.4 (#152923) --- 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 39ff0bc184c8..674ced0bf9c6 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==41.9.3", + "aioesphomeapi==41.9.4", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.3.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 7d3421674a52..6feb2fe6840c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.9.3 +aioesphomeapi==41.9.4 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bec1bb02bf22..249f309297cb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.9.3 +aioesphomeapi==41.9.4 # homeassistant.components.flo aioflo==2021.11.0 From 0b0f8c5829a1a3b76dec8695e828c3d5cd389ccc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 24 Sep 2025 22:15:29 -0400 Subject: [PATCH 141/189] Remove some more domains from common controls (#152927) --- homeassistant/components/usage_prediction/common_control.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/usage_prediction/common_control.py b/homeassistant/components/usage_prediction/common_control.py index 995d3c5a559c..9d86b5f27666 100644 --- a/homeassistant/components/usage_prediction/common_control.py +++ b/homeassistant/components/usage_prediction/common_control.py @@ -38,13 +38,11 @@ ALLOWED_DOMAINS = { Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.BUTTON, - Platform.CALENDAR, Platform.CAMERA, Platform.CLIMATE, Platform.COVER, Platform.FAN, Platform.HUMIDIFIER, - Platform.IMAGE, Platform.LAWN_MOWER, Platform.LIGHT, Platform.LOCK, @@ -55,7 +53,6 @@ ALLOWED_DOMAINS = { Platform.SENSOR, Platform.SIREN, Platform.SWITCH, - Platform.TEXT, Platform.VACUUM, Platform.VALVE, Platform.WATER_HEATER, From 9cd3ab853dde5cc311f4c92a38a62fb5ab267c97 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 25 Sep 2025 04:18:06 +0200 Subject: [PATCH 142/189] Add block Spook < 4.0.0 as breaking Home Assistant (#152930) --- homeassistant/loader.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 07c4a9345737..fc10223a182f 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -121,6 +121,9 @@ BLOCKED_CUSTOM_INTEGRATIONS: dict[str, BlockedIntegration] = { "variable": BlockedIntegration( AwesomeVersion("3.4.4"), "prevents recorder from working" ), + # Added in 2025.10.0 because of + # https://github.com/frenck/spook/issues/1066 + "spook": BlockedIntegration(AwesomeVersion("4.0.0"), "breaks the template engine"), } DATA_COMPONENTS: HassKey[dict[str, ModuleType | ComponentProtocol]] = HassKey( From 7c8ad9d535b713b1120a25b871aba79b12aeb05c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Sep 2025 21:27:40 -0500 Subject: [PATCH 143/189] Fix ESPHome reauth not being triggered on incorrect password (#152911) --- .../components/esphome/config_flow.py | 10 ++++- homeassistant/components/esphome/manager.py | 10 +++++ tests/components/esphome/test_config_flow.py | 38 ++++++++++++++++++- tests/components/esphome/test_dashboard.py | 4 +- tests/components/esphome/test_manager.py | 31 +++++++++++++++ 5 files changed, 89 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index e1aedb90b3cb..6197716f617e 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -57,6 +57,7 @@ from .manager import async_replace_device ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key" ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk" +ERROR_INVALID_PASSWORD_AUTH = "invalid_auth" _LOGGER = logging.getLogger(__name__) ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" @@ -137,6 +138,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._password = "" return await self._async_authenticate_or_add() + if error == ERROR_INVALID_PASSWORD_AUTH or ( + error is None and self._device_info and self._device_info.uses_password + ): + return await self.async_step_authenticate() + if error is None and entry_data.get(CONF_NOISE_PSK): # Device was configured with encryption but now connects without it. # Check if it's the same device before offering to remove encryption. @@ -690,13 +696,15 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): cli = APIClient( host, port or DEFAULT_PORT, - "", + self._password or "", zeroconf_instance=zeroconf_instance, noise_psk=noise_psk, ) try: await cli.connect() self._device_info = await cli.device_info() + except InvalidAuthAPIError: + return ERROR_INVALID_PASSWORD_AUTH except RequiresEncryptionAPIError: return ERROR_REQUIRES_ENCRYPTION_KEY except InvalidEncryptionKeyAPIError as ex: diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index a14eb3f5a164..c3db4c3e9e8e 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -372,6 +372,9 @@ class ESPHomeManager: """Subscribe to states and list entities on successful API login.""" try: await self._on_connect() + except InvalidAuthAPIError as err: + _LOGGER.warning("Authentication failed for %s: %s", self.host, err) + await self._start_reauth_and_disconnect() except APIConnectionError as err: _LOGGER.warning( "Error getting setting up connection for %s: %s", self.host, err @@ -641,7 +644,14 @@ class ESPHomeManager: if self.reconnect_logic: await self.reconnect_logic.stop() return + await self._start_reauth_and_disconnect() + + async def _start_reauth_and_disconnect(self) -> None: + """Start reauth flow and stop reconnection attempts.""" self.entry.async_start_reauth(self.hass) + await self.cli.disconnect() + if self.reconnect_logic: + await self.reconnect_logic.stop() async def _handle_dynamic_encryption_key( self, device_info: EsphomeDeviceInfo diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index f3bb1c77e408..27d585bea6f3 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -1184,6 +1184,42 @@ async def test_reauth_attempt_to_change_mac_aborts( } +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reauth_password_changed( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reauth when password has changed.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: "old_password"}, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + mock_client.connect.side_effect = InvalidAuthAPIError("Invalid password") + + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == { + "name": "Mock Title", + } + + mock_client.connect.side_effect = None + mock_client.connect.return_value = None + mock_client.device_info.return_value = DeviceInfo( + uses_password=True, name="test", mac_address="11:22:33:44:55:aa" + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "new_password"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data[CONF_PASSWORD] == "new_password" + + @pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_reauth_fixed_via_dashboard( hass: HomeAssistant, @@ -1239,7 +1275,7 @@ async def test_reauth_fixed_via_dashboard_add_encryption_remove_password( ) -> None: """Test reauth fixed automatically via dashboard with password removed.""" mock_client.device_info.side_effect = ( - InvalidAuthAPIError, + InvalidEncryptionKeyAPIError("Wrong key", "test"), DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:aa"), ) diff --git a/tests/components/esphome/test_dashboard.py b/tests/components/esphome/test_dashboard.py index 340a10a86d16..36542b2bd098 100644 --- a/tests/components/esphome/test_dashboard.py +++ b/tests/components/esphome/test_dashboard.py @@ -3,7 +3,7 @@ from typing import Any from unittest.mock import patch -from aioesphomeapi import APIClient, DeviceInfo, InvalidAuthAPIError +from aioesphomeapi import APIClient, DeviceInfo, InvalidEncryptionKeyAPIError import pytest from homeassistant.components.esphome import CONF_NOISE_PSK, DOMAIN, dashboard @@ -194,7 +194,7 @@ async def test_new_dashboard_fix_reauth( ) -> None: """Test config entries waiting for reauth are triggered.""" mock_client.device_info.side_effect = ( - InvalidAuthAPIError, + InvalidEncryptionKeyAPIError("Wrong key", "test"), DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:AA"), ) diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 86dfb6e9ea3f..319d70b4e426 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -1455,6 +1455,37 @@ async def test_no_reauth_wrong_mac( ) +async def test_auth_error_during_on_connect_triggers_reauth( + hass: HomeAssistant, + mock_client: APIClient, +) -> None: + """Test that InvalidAuthAPIError during on_connect triggers reauth.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="11:22:33:44:55:aa", + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "wrong_password", + }, + ) + entry.add_to_hass(hass) + + mock_client.device_info_and_list_entities = AsyncMock( + side_effect=InvalidAuthAPIError("Invalid password!") + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress(DOMAIN) + assert len(flows) == 1 + assert flows[0]["context"]["source"] == "reauth" + assert flows[0]["context"]["entry_id"] == entry.entry_id + assert mock_client.disconnect.call_count >= 1 + + async def test_entry_missing_unique_id( hass: HomeAssistant, mock_client: APIClient, From 91e13d447a0aca0cdea90d8a824cd5b4537acac8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 24 Sep 2025 23:09:54 -0400 Subject: [PATCH 144/189] Prevent common control calling async methods from thread (#152931) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../usage_prediction/common_control.py | 112 +++++++++--------- .../usage_prediction/test_common_control.py | 61 +++++++--- 2 files changed, 101 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/usage_prediction/common_control.py b/homeassistant/components/usage_prediction/common_control.py index 9d86b5f27666..69f2164fc763 100644 --- a/homeassistant/components/usage_prediction/common_control.py +++ b/homeassistant/components/usage_prediction/common_control.py @@ -3,13 +3,14 @@ from __future__ import annotations from collections import Counter -from collections.abc import Callable +from collections.abc import Callable, Sequence from datetime import datetime, timedelta from functools import cache import logging from typing import Any, Literal, cast from sqlalchemy import select +from sqlalchemy.engine.row import Row from sqlalchemy.orm import Session from homeassistant.components.recorder import get_instance @@ -90,61 +91,32 @@ async def async_predict_common_control( Args: hass: Home Assistant instance user_id: User ID to filter events by. - - Returns: - Dictionary with time categories as keys and lists of most common entity IDs as values """ # Get the recorder instance to ensure it's ready recorder = get_instance(hass) ent_reg = er.async_get(hass) # Execute the database operation in the recorder's executor - return await recorder.async_add_executor_job( + data = await recorder.async_add_executor_job( _fetch_with_session, hass, _fetch_and_process_data, ent_reg, user_id ) - - -def _fetch_and_process_data( - session: Session, ent_reg: er.EntityRegistry, user_id: str -) -> EntityUsagePredictions: - """Fetch and process service call events from the database.""" # Prepare a dictionary to track results results: dict[str, Counter[str]] = { time_cat: Counter() for time_cat in TIME_CATEGORIES } + allowed_entities = set(hass.states.async_entity_ids(ALLOWED_DOMAINS)) + hidden_entities: set[str] = set() + # Keep track of contexts that we processed so that we will only process # the first service call in a context, and not subsequent calls. context_processed: set[bytes] = set() - thirty_days_ago_ts = (dt_util.utcnow() - timedelta(days=30)).timestamp() - user_id_bytes = uuid_hex_to_bytes_or_none(user_id) - if not user_id_bytes: - raise ValueError("Invalid user_id format") - - # Build the main query for events with their data - query = ( - select( - Events.context_id_bin, - Events.time_fired_ts, - EventData.shared_data, - ) - .select_from(Events) - .outerjoin(EventData, Events.data_id == EventData.data_id) - .outerjoin(EventTypes, Events.event_type_id == EventTypes.event_type_id) - .where(Events.time_fired_ts >= thirty_days_ago_ts) - .where(Events.context_user_id_bin == user_id_bytes) - .where(EventTypes.event_type == "call_service") - .order_by(Events.time_fired_ts) - ) - # Execute the query context_id: bytes time_fired_ts: float shared_data: str | None local_time_zone = dt_util.get_default_time_zone() - for context_id, time_fired_ts, shared_data in ( - session.connection().execute(query).all() - ): + for context_id, time_fired_ts, shared_data in data: # Skip if we have already processed an event that was part of this context if context_id in context_processed: continue @@ -153,7 +125,7 @@ def _fetch_and_process_data( context_processed.add(context_id) # Parse the event data - if not shared_data: + if not time_fired_ts or not shared_data: continue try: @@ -187,27 +159,26 @@ def _fetch_and_process_data( if not isinstance(entity_ids, list): entity_ids = [entity_ids] - # Filter out entity IDs that are not in allowed domains - entity_ids = [ - entity_id - for entity_id in entity_ids - if entity_id.split(".")[0] in ALLOWED_DOMAINS - and ((entry := ent_reg.async_get(entity_id)) is None or not entry.hidden) - ] + # Convert to local time for time category determination + period = time_category( + datetime.fromtimestamp(time_fired_ts, local_time_zone).hour + ) + period_results = results[period] - if not entity_ids: - continue + # Count entity usage + for entity_id in entity_ids: + if entity_id not in allowed_entities or entity_id in hidden_entities: + continue - # Convert timestamp to datetime and determine time category - if time_fired_ts: - # Convert to local time for time category determination - period = time_category( - datetime.fromtimestamp(time_fired_ts, local_time_zone).hour - ) + if ( + entity_id not in period_results + and (entry := ent_reg.async_get(entity_id)) + and entry.hidden + ): + hidden_entities.add(entity_id) + continue - # Count entity usage - for entity_id in entity_ids: - results[period][entity_id] += 1 + period_results[entity_id] += 1 return EntityUsagePredictions( morning=[ @@ -226,11 +197,40 @@ def _fetch_and_process_data( ) +def _fetch_and_process_data( + session: Session, ent_reg: er.EntityRegistry, user_id: str +) -> Sequence[Row[tuple[bytes | None, float | None, str | None]]]: + """Fetch and process service call events from the database.""" + thirty_days_ago_ts = (dt_util.utcnow() - timedelta(days=30)).timestamp() + user_id_bytes = uuid_hex_to_bytes_or_none(user_id) + if not user_id_bytes: + raise ValueError("Invalid user_id format") + + # Build the main query for events with their data + query = ( + select( + Events.context_id_bin, + Events.time_fired_ts, + EventData.shared_data, + ) + .select_from(Events) + .outerjoin(EventData, Events.data_id == EventData.data_id) + .outerjoin(EventTypes, Events.event_type_id == EventTypes.event_type_id) + .where(Events.time_fired_ts >= thirty_days_ago_ts) + .where(Events.context_user_id_bin == user_id_bytes) + .where(EventTypes.event_type == "call_service") + .order_by(Events.time_fired_ts) + ) + return session.connection().execute(query).all() + + def _fetch_with_session( hass: HomeAssistant, - fetch_func: Callable[[Session], EntityUsagePredictions], + fetch_func: Callable[ + [Session], Sequence[Row[tuple[bytes | None, float | None, str | None]]] + ], *args: object, -) -> EntityUsagePredictions: +) -> Sequence[Row[tuple[bytes | None, float | None, str | None]]]: """Execute a fetch function with a database session.""" with session_scope(hass=hass, read_only=True) as session: return fetch_func(session, *args) diff --git a/tests/components/usage_prediction/test_common_control.py b/tests/components/usage_prediction/test_common_control.py index de6db0254722..090d9ddf7ffc 100644 --- a/tests/components/usage_prediction/test_common_control.py +++ b/tests/components/usage_prediction/test_common_control.py @@ -62,9 +62,15 @@ async def test_with_service_calls(hass: HomeAssistant) -> None: """Test function with actual service call events in database.""" user_id = str(uuid.uuid4()) + hass.states.async_set("light.living_room", "off") + hass.states.async_set("light.kitchen", "off") + hass.states.async_set("climate.thermostat", "off") + hass.states.async_set("light.bedroom", "off") + hass.states.async_set("lock.front_door", "locked") + # Create service call events at different times of day # Morning events - use separate service calls to get around context deduplication - with freeze_time("2023-07-01 07:00:00+00:00"): # Morning + with freeze_time("2023-07-01 07:00:00"): # Morning hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -77,7 +83,7 @@ async def test_with_service_calls(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Afternoon events - with freeze_time("2023-07-01 14:00:00+00:00"): # Afternoon + with freeze_time("2023-07-01 14:00:00"): # Afternoon hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -90,7 +96,7 @@ async def test_with_service_calls(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Evening events - with freeze_time("2023-07-01 19:00:00+00:00"): # Evening + with freeze_time("2023-07-01 19:00:00"): # Evening hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -103,7 +109,7 @@ async def test_with_service_calls(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Night events - with freeze_time("2023-07-01 23:00:00+00:00"): # Night + with freeze_time("2023-07-01 23:00:00"): # Night hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -119,7 +125,7 @@ async def test_with_service_calls(hass: HomeAssistant) -> None: await async_wait_recording_done(hass) # Get predictions - make sure we're still in a reasonable timeframe - with freeze_time("2023-07-02 10:00:00+00:00"): # Next day, so events are recent + with freeze_time("2023-07-02 10:00:00"): # Next day, so events are recent results = await async_predict_common_control(hass, user_id) # Verify results contain the expected entities in the correct time periods @@ -151,7 +157,12 @@ async def test_multiple_entities_in_one_call(hass: HomeAssistant) -> None: suggested_object_id="kitchen", ) - with freeze_time("2023-07-01 10:00:00+00:00"): # Morning + hass.states.async_set("light.living_room", "off") + hass.states.async_set("light.kitchen", "off") + hass.states.async_set("light.hallway", "off") + hass.states.async_set("not_allowed.domain", "off") + + with freeze_time("2023-07-01 10:00:00"): # Morning hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -163,6 +174,7 @@ async def test_multiple_entities_in_one_call(hass: HomeAssistant) -> None: "light.kitchen", "light.hallway", "not_allowed.domain", + "light.not_in_state_machine", ] }, }, @@ -172,7 +184,7 @@ async def test_multiple_entities_in_one_call(hass: HomeAssistant) -> None: await async_wait_recording_done(hass) - with freeze_time("2023-07-02 10:00:00+00:00"): # Next day, so events are recent + with freeze_time("2023-07-02 10:00:00"): # Next day, so events are recent results = await async_predict_common_control(hass, user_id) # Two lights should be counted (10:00 UTC = 02:00 local = night) @@ -189,7 +201,10 @@ async def test_context_deduplication(hass: HomeAssistant) -> None: user_id = str(uuid.uuid4()) context = Context(user_id=user_id) - with freeze_time("2023-07-01 10:00:00+00:00"): # Morning + hass.states.async_set("light.living_room", "off") + hass.states.async_set("switch.coffee_maker", "off") + + with freeze_time("2023-07-01 10:00:00"): # Morning # Fire multiple events with the same context hass.bus.async_fire( EVENT_CALL_SERVICE, @@ -215,7 +230,7 @@ async def test_context_deduplication(hass: HomeAssistant) -> None: await async_wait_recording_done(hass) - with freeze_time("2023-07-02 10:00:00+00:00"): # Next day, so events are recent + with freeze_time("2023-07-02 10:00:00"): # Next day, so events are recent results = await async_predict_common_control(hass, user_id) # Only the first event should be processed (10:00 UTC = 02:00 local = night) @@ -232,8 +247,11 @@ async def test_old_events_excluded(hass: HomeAssistant) -> None: """Test that events older than 30 days are excluded.""" user_id = str(uuid.uuid4()) + hass.states.async_set("light.old_event", "off") + hass.states.async_set("light.recent_event", "off") + # Create an old event (35 days ago) - with freeze_time("2023-05-27 10:00:00+00:00"): # 35 days before July 1st + with freeze_time("2023-05-27 10:00:00"): # 35 days before July 1st hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -246,7 +264,7 @@ async def test_old_events_excluded(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Create a recent event (5 days ago) - with freeze_time("2023-06-26 10:00:00+00:00"): # 5 days before July 1st + with freeze_time("2023-06-26 10:00:00"): # 5 days before July 1st hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -261,7 +279,7 @@ async def test_old_events_excluded(hass: HomeAssistant) -> None: await async_wait_recording_done(hass) # Query with current time - with freeze_time("2023-07-01 10:00:00+00:00"): + with freeze_time("2023-07-01 10:00:00"): results = await async_predict_common_control(hass, user_id) # Only recent event should be included (10:00 UTC = 02:00 local = night) @@ -278,8 +296,16 @@ async def test_entities_limit(hass: HomeAssistant) -> None: """Test that only top entities are returned per time category.""" user_id = str(uuid.uuid4()) + hass.states.async_set("light.most_used", "off") + hass.states.async_set("light.second", "off") + hass.states.async_set("light.third", "off") + hass.states.async_set("light.fourth", "off") + hass.states.async_set("light.fifth", "off") + hass.states.async_set("light.sixth", "off") + hass.states.async_set("light.seventh", "off") + # Create more than 5 different entities in morning - with freeze_time("2023-07-01 08:00:00+00:00"): + with freeze_time("2023-07-01 08:00:00"): # Create entities with different frequencies entities_with_counts = [ ("light.most_used", 10), @@ -308,7 +334,7 @@ async def test_entities_limit(hass: HomeAssistant) -> None: await async_wait_recording_done(hass) with ( - freeze_time("2023-07-02 10:00:00+00:00"), + freeze_time("2023-07-02 10:00:00"), patch( "homeassistant.components.usage_prediction.common_control.RESULTS_TO_INCLUDE", 5, @@ -335,7 +361,10 @@ async def test_different_users_separated(hass: HomeAssistant) -> None: user_id_1 = str(uuid.uuid4()) user_id_2 = str(uuid.uuid4()) - with freeze_time("2023-07-01 10:00:00+00:00"): + hass.states.async_set("light.user1_light", "off") + hass.states.async_set("light.user2_light", "off") + + with freeze_time("2023-07-01 10:00:00"): # User 1 events hass.bus.async_fire( EVENT_CALL_SERVICE, @@ -363,7 +392,7 @@ async def test_different_users_separated(hass: HomeAssistant) -> None: await async_wait_recording_done(hass) # Get results for each user - with freeze_time("2023-07-02 10:00:00+00:00"): # Next day, so events are recent + with freeze_time("2023-07-02 10:00:00"): # Next day, so events are recent results_user1 = await async_predict_common_control(hass, user_id_1) results_user2 = await async_predict_common_control(hass, user_id_2) From 724a7b0ecc7faf69832938cf74eefe2b4bed7975 Mon Sep 17 00:00:00 2001 From: Jimmy Zhening Luo <1450044+jimmy-zhening-luo@users.noreply.github.com> Date: Thu, 25 Sep 2025 00:06:13 -0700 Subject: [PATCH 145/189] Quality: mark installation param doc as done (#152909) --- homeassistant/components/litterrobot/quality_scale.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/litterrobot/quality_scale.yaml b/homeassistant/components/litterrobot/quality_scale.yaml index 82f01f64d18e..3b26500da979 100644 --- a/homeassistant/components/litterrobot/quality_scale.yaml +++ b/homeassistant/components/litterrobot/quality_scale.yaml @@ -28,7 +28,7 @@ rules: docs-configuration-parameters: status: done comment: No options to configure - docs-installation-parameters: todo + docs-installation-parameters: done entity-unavailable: todo integration-owner: done log-when-unavailable: todo From 31017ebc98529469f5be6a9ab25ce69aca2e7cbd Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Thu, 25 Sep 2025 03:39:52 -0400 Subject: [PATCH 146/189] Fix logical error when user has no Roborock maps (#152752) --- .../components/roborock/coordinator.py | 10 ++----- tests/components/roborock/test_coordinator.py | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 39966273908d..e36208dfee11 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -351,13 +351,9 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): def _set_current_map(self) -> None: if ( self.roborock_device_info.props.status is not None - and self.roborock_device_info.props.status.map_status is not None + and self.roborock_device_info.props.status.current_map is not None ): - # The map status represents the map flag as flag * 4 + 3 - - # so we have to invert that in order to get the map flag that we can use to set the current map. - self.current_map = ( - self.roborock_device_info.props.status.map_status - 3 - ) // 4 + self.current_map = self.roborock_device_info.props.status.current_map async def set_current_map_rooms(self) -> None: """Fetch all of the rooms for the current map and set on RoborockMapInfo.""" @@ -440,7 +436,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # If either of these fail, we don't care, and we want to continue. await asyncio.gather(*tasks, return_exceptions=True) - if len(self.maps) != 1: + if len(self.maps) > 1: # Set the map back to the map the user previously had selected so that it # does not change the end user's app. # Only needs to happen when we changed maps above. diff --git a/tests/components/roborock/test_coordinator.py b/tests/components/roborock/test_coordinator.py index 22efddf5817f..7da19e9418cb 100644 --- a/tests/components/roborock/test_coordinator.py +++ b/tests/components/roborock/test_coordinator.py @@ -5,6 +5,7 @@ from datetime import timedelta from unittest.mock import patch import pytest +from roborock import MultiMapsList from roborock.exceptions import RoborockException from vacuum_map_parser_base.config.color import SupportedColor @@ -135,3 +136,30 @@ async def test_dynamic_local_scan_interval( async_fire_time_changed(hass, dt_util.utcnow() + interval) assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "20" + + +async def test_no_maps( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture: None, +) -> None: + """Test that a device with no maps is handled correctly.""" + prop = copy.deepcopy(PROP) + prop.status.map_status = 252 + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", + return_value=prop, + ), + patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_multi_maps_list", + return_value=MultiMapsList( + max_multi_map=1, max_bak_map=1, multi_map_count=0, map_info=[] + ), + ), + patch( + "homeassistant.components.roborock.RoborockMqttClientV1.load_multi_map" + ) as load_map, + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert load_map.call_count == 0 From 7d6eac9ff7efb9918836c2169e3783ab8cdd4e61 Mon Sep 17 00:00:00 2001 From: Sab44 <64696149+Sab44@users.noreply.github.com> Date: Thu, 25 Sep 2025 09:42:31 +0200 Subject: [PATCH 147/189] Bump librehardwaremonitor-api to version 1.4.0 (#152938) --- homeassistant/components/libre_hardware_monitor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/libre_hardware_monitor/manifest.json b/homeassistant/components/libre_hardware_monitor/manifest.json index 66623db1f2d7..322f3f2934f1 100644 --- a/homeassistant/components/libre_hardware_monitor/manifest.json +++ b/homeassistant/components/libre_hardware_monitor/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/libre_hardware_monitor", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["librehardwaremonitor-api==1.3.1"] + "requirements": ["librehardwaremonitor-api==1.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6feb2fe6840c..3830c097adc3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1364,7 +1364,7 @@ libpyfoscamcgi==0.0.7 libpyvivotek==0.4.0 # homeassistant.components.libre_hardware_monitor -librehardwaremonitor-api==1.3.1 +librehardwaremonitor-api==1.4.0 # homeassistant.components.mikrotik librouteros==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 249f309297cb..fb508dd12fb2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1180,7 +1180,7 @@ letpot==0.6.2 libpyfoscamcgi==0.0.7 # homeassistant.components.libre_hardware_monitor -librehardwaremonitor-api==1.3.1 +librehardwaremonitor-api==1.4.0 # homeassistant.components.mikrotik librouteros==3.2.0 From 25849fd9ccd9f716da473061d247937962aad9ab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Sep 2025 09:43:03 +0200 Subject: [PATCH 148/189] Bump actions/cache from 4.2.4 to 4.3.0 (#152934) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 60 +++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3cad6a4e5324..e5b4a6614e6f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -263,7 +263,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: venv key: >- @@ -279,7 +279,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@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ env.PRE_COMMIT_CACHE }} lookup-only: true @@ -309,7 +309,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: venv fail-on-cache-miss: true @@ -318,7 +318,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -349,7 +349,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: venv fail-on-cache-miss: true @@ -358,7 +358,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -389,7 +389,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: venv fail-on-cache-miss: true @@ -398,7 +398,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -505,7 +505,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@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: venv key: >- @@ -513,7 +513,7 @@ jobs: needs.info.outputs.python_cache_key }} - name: Restore uv wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ env.UV_CACHE_DIR }} key: >- @@ -525,7 +525,7 @@ jobs: env.HA_SHORT_VERSION }}- - name: Check if apt cache exists id: cache-apt-check - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }} path: | @@ -570,7 +570,7 @@ jobs: fi - name: Save apt cache if: steps.cache-apt-check.outputs.cache-hit != 'true' - uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | ${{ env.APT_CACHE_DIR }} @@ -622,7 +622,7 @@ jobs: - base steps: - name: Restore apt cache - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@v4.3.0 with: path: | ${{ env.APT_CACHE_DIR }} @@ -651,7 +651,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: venv fail-on-cache-miss: true @@ -684,7 +684,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: venv fail-on-cache-miss: true @@ -741,7 +741,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: venv fail-on-cache-miss: true @@ -784,7 +784,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: venv fail-on-cache-miss: true @@ -831,7 +831,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: venv fail-on-cache-miss: true @@ -883,7 +883,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@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: venv fail-on-cache-miss: true @@ -891,7 +891,7 @@ jobs: ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .mypy_cache key: >- @@ -935,7 +935,7 @@ jobs: name: Split tests for full run steps: - name: Restore apt cache - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@v4.3.0 with: path: | ${{ env.APT_CACHE_DIR }} @@ -967,7 +967,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: venv fail-on-cache-miss: true @@ -1009,7 +1009,7 @@ jobs: Run tests Python ${{ matrix.python-version }} (${{ matrix.group }}) steps: - name: Restore apt cache - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@v4.3.0 with: path: | ${{ env.APT_CACHE_DIR }} @@ -1042,7 +1042,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: venv fail-on-cache-miss: true @@ -1156,7 +1156,7 @@ jobs: Run ${{ matrix.mariadb-group }} tests Python ${{ matrix.python-version }} steps: - name: Restore apt cache - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@v4.3.0 with: path: | ${{ env.APT_CACHE_DIR }} @@ -1189,7 +1189,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: venv fail-on-cache-miss: true @@ -1310,7 +1310,7 @@ jobs: Run ${{ matrix.postgresql-group }} tests Python ${{ matrix.python-version }} steps: - name: Restore apt cache - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@v4.3.0 with: path: | ${{ env.APT_CACHE_DIR }} @@ -1345,7 +1345,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: venv fail-on-cache-miss: true @@ -1485,7 +1485,7 @@ jobs: Run tests Python ${{ matrix.python-version }} (${{ matrix.group }}) steps: - name: Restore apt cache - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@v4.3.0 with: path: | ${{ env.APT_CACHE_DIR }} @@ -1518,7 +1518,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: venv fail-on-cache-miss: true From 205bd2676bf26b49c5fd4647db946522e4e54d15 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 25 Sep 2025 09:45:50 +0200 Subject: [PATCH 149/189] Update IQS to platinum for Alexa Devices (#152905) --- homeassistant/components/alexa_devices/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 824f735b184a..437c11e0a4c1 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["aioamazondevices==6.0.0"] } From 0c8d2594ef76f39d6e9680b8aa7cc2e0558573fb Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 25 Sep 2025 09:49:22 +0200 Subject: [PATCH 150/189] Portainer fix unique entity (#152941) Co-authored-by: Franck Nijhof Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/portainer/binary_sensor.py | 10 +++++++++- homeassistant/components/portainer/entity.py | 2 +- .../portainer/snapshots/test_binary_sensor.ambr | 10 +++++----- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/portainer/binary_sensor.py b/homeassistant/components/portainer/binary_sensor.py index 5545cfc9b931..543bdeaf335d 100644 --- a/homeassistant/components/portainer/binary_sensor.py +++ b/homeassistant/components/portainer/binary_sensor.py @@ -131,7 +131,15 @@ class PortainerContainerSensor(PortainerContainerEntity, BinarySensorEntity): self.entity_description = entity_description super().__init__(device_info, coordinator, via_device) - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.id}_{entity_description.key}" + # Container ID's are ephemeral, so use the container name for the unique ID + # The first one, should always be unique, it's fine if users have aliases + # According to Docker's API docs, the first name is unique + device_identifier = ( + self._device_info.names[0].replace("/", " ").strip() + if self._device_info.names + else None + ) + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_identifier}_{entity_description.key}" @property def available(self) -> bool: diff --git a/homeassistant/components/portainer/entity.py b/homeassistant/components/portainer/entity.py index ecabafc4663f..5fd53236cd82 100644 --- a/homeassistant/components/portainer/entity.py +++ b/homeassistant/components/portainer/entity.py @@ -60,7 +60,7 @@ class PortainerContainerEntity(PortainerCoordinatorEntity): self._attr_device_info = DeviceInfo( identifiers={ - (DOMAIN, f"{self.coordinator.config_entry.entry_id}_{self.device_id}") + (DOMAIN, f"{self.coordinator.config_entry.entry_id}_{device_name}") }, manufacturer=DEFAULT_NAME, model="Container", diff --git a/tests/components/portainer/snapshots/test_binary_sensor.ambr b/tests/components/portainer/snapshots/test_binary_sensor.ambr index 922b4d6cddf8..7ec3900e49bb 100644 --- a/tests/components/portainer/snapshots/test_binary_sensor.ambr +++ b/tests/components/portainer/snapshots/test_binary_sensor.ambr @@ -30,7 +30,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status', - 'unique_id': 'portainer_test_entry_123_dd19facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf_status', + 'unique_id': 'portainer_test_entry_123_focused_einstein_status', 'unit_of_measurement': None, }) # --- @@ -79,7 +79,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status', - 'unique_id': 'portainer_test_entry_123_aa86eacfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf_status', + 'unique_id': 'portainer_test_entry_123_funny_chatelet_status', 'unit_of_measurement': None, }) # --- @@ -177,7 +177,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status', - 'unique_id': 'portainer_test_entry_123_ee20facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf_status', + 'unique_id': 'portainer_test_entry_123_practical_morse_status', 'unit_of_measurement': None, }) # --- @@ -226,7 +226,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status', - 'unique_id': 'portainer_test_entry_123_bb97facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf_status', + 'unique_id': 'portainer_test_entry_123_serene_banach_status', 'unit_of_measurement': None, }) # --- @@ -275,7 +275,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status', - 'unique_id': 'portainer_test_entry_123_cc08facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf_status', + 'unique_id': 'portainer_test_entry_123_stoic_turing_status', 'unit_of_measurement': None, }) # --- From 8774295e2e770d366f8554431591b6e888ea55ef Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 25 Sep 2025 11:33:01 +0200 Subject: [PATCH 151/189] Update frontend to 20250925.0 (#152945) --- 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 11e703cd73e4..bf7c9642c131 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==20250924.0"] + "requirements": ["home-assistant-frontend==20250925.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 36f01d11b695..4867585cc4dd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==5.6.4 hass-nabucasa==1.1.1 hassil==3.2.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250924.0 +home-assistant-frontend==20250925.0 home-assistant-intents==2025.9.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 3830c097adc3..cf835109ab6c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1186,7 +1186,7 @@ hole==0.9.0 holidays==0.81 # homeassistant.components.frontend -home-assistant-frontend==20250924.0 +home-assistant-frontend==20250925.0 # homeassistant.components.conversation home-assistant-intents==2025.9.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fb508dd12fb2..6fc33e991bc1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1035,7 +1035,7 @@ hole==0.9.0 holidays==0.81 # homeassistant.components.frontend -home-assistant-frontend==20250924.0 +home-assistant-frontend==20250925.0 # homeassistant.components.conversation home-assistant-intents==2025.9.24 From 0ae272f1f686e2dbf4ab9f9fd47ff0733c2fe6d0 Mon Sep 17 00:00:00 2001 From: Karsten Bade Date: Thu, 25 Sep 2025 11:34:38 +0200 Subject: [PATCH 152/189] Add return types and docstring to sonos component (#152946) --- homeassistant/components/sonos/media_player.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index a21aca70d2ec..a2719ec6ba93 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -610,7 +610,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): def _play_media_queue( self, soco: SoCo, item: MusicServiceItem, enqueue: MediaPlayerEnqueue - ): + ) -> None: """Manage adding, replacing, playing items onto the sonos queue.""" _LOGGER.debug( "_play_media_queue item_id [%s] title [%s] enqueue [%s]", @@ -639,7 +639,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): media_type: MediaType | str, media_id: str, enqueue: MediaPlayerEnqueue, - ): + ) -> None: """Play a directory from a music library share.""" item = media_browser.get_media(self.media.library, media_id, media_type) if not item: @@ -660,6 +660,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): enqueue: MediaPlayerEnqueue, title: str, ) -> None: + """Play a sharelink.""" share_link = self.coordinator.share_link kwargs = {} if title: From 3f8f7573c96bda8ab8594d13c7e42ffb9bfd8363 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 25 Sep 2025 12:34:14 +0200 Subject: [PATCH 153/189] Bump hass-nabucasa from 1.1.1 to 1.1.2 (#152950) --- 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 0625054869d8..1912c20e8d8d 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==1.1.1"], + "requirements": ["hass-nabucasa==1.1.2"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4867585cc4dd..f9d165d5b3b4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -36,7 +36,7 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==5.6.4 -hass-nabucasa==1.1.1 +hass-nabucasa==1.1.2 hassil==3.2.0 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250925.0 diff --git a/pyproject.toml b/pyproject.toml index ae1d8fa5c10d..4b84c63d951b 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==1.1.1", + "hass-nabucasa==1.1.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 0f161b69c202..237ecebb6614 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.3 cronsim==2.6 fnv-hash-fast==1.5.0 -hass-nabucasa==1.1.1 +hass-nabucasa==1.1.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 cf835109ab6c..cf251a0784ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1145,7 +1145,7 @@ habiticalib==0.4.5 habluetooth==5.6.4 # homeassistant.components.cloud -hass-nabucasa==1.1.1 +hass-nabucasa==1.1.2 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6fc33e991bc1..3c6183a4199e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1006,7 +1006,7 @@ habiticalib==0.4.5 habluetooth==5.6.4 # homeassistant.components.cloud -hass-nabucasa==1.1.1 +hass-nabucasa==1.1.2 # homeassistant.components.assist_satellite # homeassistant.components.conversation From 834e3f196371defdafc37b408c59d914ef6f5bf1 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Thu, 25 Sep 2025 13:05:40 +0100 Subject: [PATCH 154/189] Add HassKey for hass.data in Squeezebox (#149129) --- homeassistant/components/squeezebox/__init__.py | 9 ++++++--- homeassistant/components/squeezebox/const.py | 1 - homeassistant/components/squeezebox/media_player.py | 10 ++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index 2bd845923fc0..c7411e935dfd 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -1,5 +1,6 @@ """The Squeezebox integration.""" +import asyncio from asyncio import timeout from dataclasses import dataclass, field from datetime import datetime @@ -31,11 +32,11 @@ from homeassistant.helpers.device_registry import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later +from homeassistant.util.hass_dict import HassKey from .const import ( CONF_HTTPS, DISCOVERY_INTERVAL, - DISCOVERY_TASK, DOMAIN, SERVER_MANUFACTURER, SERVER_MODEL, @@ -64,6 +65,8 @@ PLATFORMS = [ Platform.UPDATE, ] +SQUEEZEBOX_HASS_DATA: HassKey[asyncio.Task] = HassKey(DOMAIN) + @dataclass class SqueezeboxData: @@ -240,7 +243,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) current_entries = hass.config_entries.async_entries(DOMAIN) if len(current_entries) == 1 and current_entries[0] == entry: _LOGGER.debug("Stopping server discovery task") - hass.data[DOMAIN][DISCOVERY_TASK].cancel() - hass.data[DOMAIN].pop(DISCOVERY_TASK) + hass.data[SQUEEZEBOX_HASS_DATA].cancel() + hass.data.pop(SQUEEZEBOX_HASS_DATA) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index 091ef4d1bbda..b61d28943cfa 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -1,7 +1,6 @@ """Constants for the Squeezebox component.""" CONF_HTTPS = "https" -DISCOVERY_TASK = "discovery_task" DOMAIN = "squeezebox" DEFAULT_PORT = 9000 PLAYER_DISCOVERY_UNSUB = "player_discovery_unsub" diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index a5f5288807f3..d1313eccc37b 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -44,6 +44,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.start import async_at_start from homeassistant.util.dt import utcnow +from . import SQUEEZEBOX_HASS_DATA from .browse_media import ( BrowseData, build_item_response, @@ -58,7 +59,6 @@ from .const import ( CONF_VOLUME_STEP, DEFAULT_BROWSE_LIMIT, DEFAULT_VOLUME_STEP, - DISCOVERY_TASK, DOMAIN, SERVER_MANUFACTURER, SERVER_MODEL, @@ -110,12 +110,10 @@ async def start_server_discovery(hass: HomeAssistant) -> None: }, ) - hass.data.setdefault(DOMAIN, {}) - if DISCOVERY_TASK not in hass.data[DOMAIN]: + if not hass.data.get(SQUEEZEBOX_HASS_DATA): _LOGGER.debug("Adding server discovery task for squeezebox") - hass.data[DOMAIN][DISCOVERY_TASK] = hass.async_create_background_task( - async_discover(_discovered_server), - name="squeezebox server discovery", + hass.data[SQUEEZEBOX_HASS_DATA] = hass.async_create_background_task( + async_discover(_discovered_server), name="squeezebox server discovery" ) From cf1a745283253dde6a926e108abe64fe1205edc3 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 25 Sep 2025 15:55:50 +0200 Subject: [PATCH 155/189] Move condition-specific fields into options (#152635) --- .../components/device_automation/condition.py | 40 ++++++-- homeassistant/components/sun/condition.py | 64 +++++++----- homeassistant/components/zone/condition.py | 50 ++++++---- .../components/zwave_js/triggers/event.py | 2 +- .../zwave_js/triggers/value_updated.py | 2 +- homeassistant/helpers/automation.py | 31 ++++++ homeassistant/helpers/condition.py | 77 ++++++++++++--- homeassistant/helpers/trigger.py | 23 ----- tests/components/sun/test_condition.py | 51 ++++++---- tests/components/zone/test_condition.py | 21 ++-- tests/helpers/test_automation.py | 72 ++++++++++++++ tests/helpers/test_condition.py | 97 ++++++++++++++++--- tests/helpers/test_trigger.py | 72 +------------- 13 files changed, 405 insertions(+), 197 deletions(-) diff --git a/homeassistant/components/device_automation/condition.py b/homeassistant/components/device_automation/condition.py index 63be9641aeb9..a37a72cdcf4d 100644 --- a/homeassistant/components/device_automation/condition.py +++ b/homeassistant/components/device_automation/condition.py @@ -6,12 +6,13 @@ from typing import TYPE_CHECKING, Any, Protocol import voluptuous as vol -from homeassistant.const import CONF_DOMAIN +from homeassistant.const import CONF_DOMAIN, CONF_OPTIONS from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.condition import ( Condition, ConditionCheckerType, + ConditionConfig, trace_condition_function, ) from homeassistant.helpers.typing import ConfigType @@ -55,19 +56,40 @@ class DeviceAutomationConditionProtocol(Protocol): class DeviceCondition(Condition): """Device condition.""" - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: - """Initialize condition.""" - self._config = config - self._hass = hass + _hass: HomeAssistant + _config: ConfigType + + @classmethod + async def async_validate_complete_config( + cls, hass: HomeAssistant, complete_config: ConfigType + ) -> ConfigType: + """Validate complete config.""" + complete_config = await async_validate_device_automation_config( + hass, + complete_config, + cv.DEVICE_CONDITION_SCHEMA, + DeviceAutomationType.CONDITION, + ) + # Since we don't want to migrate device conditions to a new format + # we just pass the entire config as options. + complete_config[CONF_OPTIONS] = complete_config.copy() + return complete_config @classmethod async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: - """Validate device condition config.""" - return await async_validate_device_automation_config( - hass, config, cv.DEVICE_CONDITION_SCHEMA, DeviceAutomationType.CONDITION - ) + """Validate config. + + This is here just to satisfy the abstract class interface. It is never called. + """ + raise NotImplementedError + + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Initialize condition.""" + self._hass = hass + assert config.options is not None + self._config = config.options async def async_get_checker(self) -> condition.ConditionCheckerType: """Test a device condition.""" diff --git a/homeassistant/components/sun/condition.py b/homeassistant/components/sun/condition.py index 415d0a04e7ce..f748a6da8bc5 100644 --- a/homeassistant/components/sun/condition.py +++ b/homeassistant/components/sun/condition.py @@ -3,16 +3,18 @@ from __future__ import annotations from datetime import datetime, timedelta -from typing import cast +from typing import Any, cast import voluptuous as vol -from homeassistant.const import CONF_CONDITION, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET +from homeassistant.const import CONF_OPTIONS, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.automation import move_top_level_schema_fields_to_options from homeassistant.helpers.condition import ( Condition, ConditionCheckerType, + ConditionConfig, condition_trace_set_result, condition_trace_update_result, trace_condition_function, @@ -21,20 +23,22 @@ from homeassistant.helpers.sun import get_astral_event_date from homeassistant.helpers.typing import ConfigType, TemplateVarsType from homeassistant.util import dt as dt_util -_CONDITION_SCHEMA = vol.All( - vol.Schema( - { - **cv.CONDITION_BASE_SCHEMA, - vol.Required(CONF_CONDITION): "sun", - vol.Optional("before"): cv.sun_event, - vol.Optional("before_offset"): cv.time_period, - vol.Optional("after"): vol.All( - vol.Lower, vol.Any(SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE) - ), - vol.Optional("after_offset"): cv.time_period, - } +_OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = { + vol.Optional("before"): cv.sun_event, + vol.Optional("before_offset"): cv.time_period, + vol.Optional("after"): vol.All( + vol.Lower, vol.Any(SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE) ), - cv.has_at_least_one_key("before", "after"), + vol.Optional("after_offset"): cv.time_period, +} + +_CONDITION_SCHEMA = vol.Schema( + { + vol.Required(CONF_OPTIONS): vol.All( + _OPTIONS_SCHEMA_DICT, + cv.has_at_least_one_key("before", "after"), + ) + } ) @@ -125,24 +129,36 @@ def sun( class SunCondition(Condition): """Sun condition.""" - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: - """Initialize condition.""" - self._config = config - self._hass = hass + _options: dict[str, Any] + + @classmethod + async def async_validate_complete_config( + cls, hass: HomeAssistant, complete_config: ConfigType + ) -> ConfigType: + """Validate complete config.""" + complete_config = move_top_level_schema_fields_to_options( + complete_config, _OPTIONS_SCHEMA_DICT + ) + return await super().async_validate_complete_config(hass, complete_config) @classmethod async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" - return _CONDITION_SCHEMA(config) # type: ignore[no-any-return] + return cast(ConfigType, _CONDITION_SCHEMA(config)) + + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Initialize condition.""" + assert config.options is not None + self._options = config.options async def async_get_checker(self) -> ConditionCheckerType: """Wrap action method with sun based condition.""" - before = self._config.get("before") - after = self._config.get("after") - before_offset = self._config.get("before_offset") - after_offset = self._config.get("after_offset") + before = self._options.get("before") + after = self._options.get("after") + before_offset = self._options.get("before_offset") + after_offset = self._options.get("after_offset") @trace_condition_function def sun_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py index cc2429ed3a42..caa75b4e0be1 100644 --- a/homeassistant/components/zone/condition.py +++ b/homeassistant/components/zone/condition.py @@ -2,14 +2,16 @@ from __future__ import annotations +from typing import Any, cast + import voluptuous as vol from homeassistant.const import ( ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE, - CONF_CONDITION, CONF_ENTITY_ID, + CONF_OPTIONS, CONF_ZONE, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -17,26 +19,25 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import ConditionErrorContainer, ConditionErrorMessage from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.automation import move_top_level_schema_fields_to_options from homeassistant.helpers.condition import ( Condition, ConditionCheckerType, + ConditionConfig, trace_condition_function, ) from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import in_zone -_CONDITION_SCHEMA = vol.Schema( - { - **cv.CONDITION_BASE_SCHEMA, - vol.Required(CONF_CONDITION): "zone", - vol.Required(CONF_ENTITY_ID): cv.entity_ids, - vol.Required("zone"): cv.entity_ids, - # To support use_trigger_value in automation - # Deprecated 2016/04/25 - vol.Optional("event"): vol.Any("enter", "leave"), - } -) +_OPTIONS_SCHEMA_DICT = { + vol.Required(CONF_ENTITY_ID): cv.entity_ids, + vol.Required("zone"): cv.entity_ids, + # To support use_trigger_value in automation + # Deprecated 2016/04/25 + vol.Optional("event"): vol.Any("enter", "leave"), +} +_CONDITION_SCHEMA = vol.Schema({CONF_OPTIONS: _OPTIONS_SCHEMA_DICT}) def zone( @@ -95,21 +96,34 @@ def zone( class ZoneCondition(Condition): """Zone condition.""" - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: - """Initialize condition.""" - self._config = config + _options: dict[str, Any] + + @classmethod + async def async_validate_complete_config( + cls, hass: HomeAssistant, complete_config: ConfigType + ) -> ConfigType: + """Validate complete config.""" + complete_config = move_top_level_schema_fields_to_options( + complete_config, _OPTIONS_SCHEMA_DICT + ) + return await super().async_validate_complete_config(hass, complete_config) @classmethod async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" - return _CONDITION_SCHEMA(config) # type: ignore[no-any-return] + return cast(ConfigType, _CONDITION_SCHEMA(config)) + + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Initialize condition.""" + assert config.options is not None + self._options = config.options 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, []) + entity_ids = self._options.get(CONF_ENTITY_ID, []) + zone_entity_ids = self._options.get(CONF_ZONE, []) @trace_condition_function def if_in_zone(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 6565e6983733..4273bf653c27 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -21,6 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.automation import move_top_level_schema_fields_to_options from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.trigger import ( Trigger, @@ -28,7 +29,6 @@ from homeassistant.helpers.trigger import ( TriggerConfig, TriggerData, TriggerInfo, - move_top_level_schema_fields_to_options, ) from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py index 14ab09961894..7ea565299d64 100644 --- a/homeassistant/components/zwave_js/triggers/value_updated.py +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -20,13 +20,13 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.automation import move_top_level_schema_fields_to_options from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.trigger import ( Trigger, TriggerActionType, TriggerConfig, TriggerInfo, - move_top_level_schema_fields_to_options, ) from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/helpers/automation.py b/homeassistant/helpers/automation.py index 52a0fc132552..85f03d8e13fc 100644 --- a/homeassistant/helpers/automation.py +++ b/homeassistant/helpers/automation.py @@ -1,5 +1,13 @@ """Helpers for automation.""" +from typing import Any + +import voluptuous as vol + +from homeassistant.const import CONF_OPTIONS + +from .typing import ConfigType + def get_absolute_description_key(domain: str, key: str) -> str: """Return the absolute description key.""" @@ -19,3 +27,26 @@ def get_relative_description_key(domain: str, key: str) -> str: if not subtype: return "_" return subtype[0] + + +def move_top_level_schema_fields_to_options( + config: ConfigType, options_schema_dict: dict[vol.Marker, Any] +) -> ConfigType: + """Move top-level fields to options. + + This function is used to help migrating old-style configs to new-style configs. + If options is already present, the config is returned as-is. + """ + if CONF_OPTIONS in config: + return config + + config = config.copy() + options = config.setdefault(CONF_OPTIONS, {}) + + # Move top-level fields to options + for key_marked in options_schema_dict: + key = key_marked.schema + if key in config: + options[key] = config.pop(key) + + return config diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 67c99eb70b47..7e162b15d8f0 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -6,6 +6,7 @@ import abc from collections import deque from collections.abc import Callable, Container, Coroutine, Generator, Iterable from contextlib import contextmanager +from dataclasses import dataclass from datetime import datetime, time as dt_time, timedelta import functools as ft import inspect @@ -30,8 +31,10 @@ from homeassistant.const import ( CONF_FOR, CONF_ID, CONF_MATCH, + CONF_OPTIONS, CONF_SELECTOR, CONF_STATE, + CONF_TARGET, CONF_VALUE_TEMPLATE, CONF_WEEKDAY, ENTITY_MATCH_ALL, @@ -111,17 +114,17 @@ CONDITIONS: HassKey[dict[str, str]] = HassKey("conditions") # Basic schemas to sanity check the condition descriptions, # full validation is done by hassfest.conditions -_FIELD_SCHEMA = vol.Schema( +_FIELD_DESCRIPTION_SCHEMA = vol.Schema( { vol.Optional(CONF_SELECTOR): selector.validate_selector, }, extra=vol.ALLOW_EXTRA, ) -_CONDITION_SCHEMA = vol.Schema( +_CONDITION_DESCRIPTION_SCHEMA = vol.Schema( { vol.Optional("target"): TargetSelector.CONFIG_SCHEMA, - vol.Optional("fields"): vol.Schema({str: _FIELD_SCHEMA}), + vol.Optional("fields"): vol.Schema({str: _FIELD_DESCRIPTION_SCHEMA}), }, extra=vol.ALLOW_EXTRA, ) @@ -134,10 +137,10 @@ def starts_with_dot(key: str) -> str: return key -_CONDITIONS_SCHEMA = vol.Schema( +_CONDITIONS_DESCRIPTION_SCHEMA = vol.Schema( { vol.Remove(vol.All(str, starts_with_dot)): object, - cv.underscore_slug: vol.Any(None, _CONDITION_SCHEMA), + cv.underscore_slug: vol.Any(None, _CONDITION_DESCRIPTION_SCHEMA), } ) @@ -199,11 +202,43 @@ async def _register_condition_platform( _LOGGER.exception("Error while notifying condition platform listener") +_CONDITION_SCHEMA = vol.Schema( + { + **cv.CONDITION_BASE_SCHEMA, + vol.Required(CONF_CONDITION): str, + vol.Optional(CONF_OPTIONS): object, + vol.Optional(CONF_TARGET): cv.TARGET_FIELDS, + } +) + + class Condition(abc.ABC): """Condition class.""" - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: - """Initialize condition.""" + @classmethod + async def async_validate_complete_config( + cls, hass: HomeAssistant, complete_config: ConfigType + ) -> ConfigType: + """Validate complete config. + + The complete config includes fields that are generic to all conditions, + such as the alias. + This method should be overridden by conditions that need to migrate + from the old-style config. + """ + complete_config = _CONDITION_SCHEMA(complete_config) + + specific_config: ConfigType = {} + for key in (CONF_OPTIONS, CONF_TARGET): + if key in complete_config: + specific_config[key] = complete_config.pop(key) + specific_config = await cls.async_validate_config(hass, specific_config) + + for key in (CONF_OPTIONS, CONF_TARGET): + if key in specific_config: + complete_config[key] = specific_config[key] + + return complete_config @classmethod @abc.abstractmethod @@ -212,6 +247,9 @@ class Condition(abc.ABC): ) -> ConfigType: """Validate config.""" + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Initialize condition.""" + @abc.abstractmethod async def async_get_checker(self) -> ConditionCheckerType: """Get the condition checker.""" @@ -226,6 +264,14 @@ class ConditionProtocol(Protocol): """Return the conditions provided by this integration.""" +@dataclass(slots=True) +class ConditionConfig: + """Condition config.""" + + options: dict[str, Any] | None = None + target: dict[str, Any] | None = None + + type ConditionCheckerType = Callable[[HomeAssistant, TemplateVarsType], bool | None] @@ -355,8 +401,15 @@ async def async_from_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() + condition_cls = condition_descriptors[relative_condition_key] + condition = condition_cls( + hass, + ConditionConfig( + options=config.get(CONF_OPTIONS), + target=config.get(CONF_TARGET), + ), + ) + return await condition.async_get_checker() for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT): factory = getattr(sys.modules[__name__], fmt.format(condition_key), None) @@ -989,9 +1042,9 @@ async def async_validate_condition_config( ) 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) + return await condition_class.async_validate_complete_config(hass, config) - if platform is None and condition_key in ("numeric_state", "state"): + if condition_key in ("numeric_state", "state"): validator = cast( Callable[[HomeAssistant, ConfigType], ConfigType], getattr( @@ -1111,7 +1164,7 @@ def _load_conditions_file(integration: Integration) -> dict[str, Any]: try: return cast( dict[str, Any], - _CONDITIONS_SCHEMA( + _CONDITIONS_DESCRIPTION_SCHEMA( load_yaml_dict(str(integration.file_path / "conditions.yaml")) ), ) diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 9ebd33678468..5c844c81cf43 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -401,29 +401,6 @@ class PluggableAction: await task -def move_top_level_schema_fields_to_options( - config: ConfigType, options_schema_dict: dict[vol.Marker, Any] -) -> ConfigType: - """Move top-level fields to options. - - This function is used to help migrating old-style configs to new-style configs. - If options is already present, the config is returned as-is. - """ - if CONF_OPTIONS in config: - return config - - config = config.copy() - options = config.setdefault(CONF_OPTIONS, {}) - - # Move top-level fields to options - for key_marked in options_schema_dict: - key = key_marked.schema - if key in config: - options[key] = config.pop(key) - - return config - - async def _async_get_trigger_platform( hass: HomeAssistant, trigger_key: str ) -> tuple[str, TriggerProtocol]: diff --git a/tests/components/sun/test_condition.py b/tests/components/sun/test_condition.py index 52c0d8854615..0375525268d4 100644 --- a/tests/components/sun/test_condition.py +++ b/tests/components/sun/test_condition.py @@ -83,7 +83,10 @@ async def test_if_action_before_sunrise_no_offset( automation.DOMAIN: { "id": "sun", "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "before": SUN_EVENT_SUNRISE}, + "condition": { + "condition": "sun", + "options": {"before": SUN_EVENT_SUNRISE}, + }, "action": {"service": "test.automation"}, } }, @@ -156,7 +159,10 @@ async def test_if_action_after_sunrise_no_offset( automation.DOMAIN: { "id": "sun", "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "after": SUN_EVENT_SUNRISE}, + "condition": { + "condition": "sun", + "options": {"after": SUN_EVENT_SUNRISE}, + }, "action": {"service": "test.automation"}, } }, @@ -231,8 +237,10 @@ async def test_if_action_before_sunrise_with_offset( "trigger": {"platform": "event", "event_type": "test_event"}, "condition": { "condition": "sun", - "before": SUN_EVENT_SUNRISE, - "before_offset": "+1:00:00", + "options": { + "before": SUN_EVENT_SUNRISE, + "before_offset": "+1:00:00", + }, }, "action": {"service": "test.automation"}, } @@ -356,8 +364,7 @@ async def test_if_action_before_sunset_with_offset( "trigger": {"platform": "event", "event_type": "test_event"}, "condition": { "condition": "sun", - "before": "sunset", - "before_offset": "+1:00:00", + "options": {"before": "sunset", "before_offset": "+1:00:00"}, }, "action": {"service": "test.automation"}, } @@ -481,8 +488,7 @@ async def test_if_action_after_sunrise_with_offset( "trigger": {"platform": "event", "event_type": "test_event"}, "condition": { "condition": "sun", - "after": SUN_EVENT_SUNRISE, - "after_offset": "+1:00:00", + "options": {"after": SUN_EVENT_SUNRISE, "after_offset": "+1:00:00"}, }, "action": {"service": "test.automation"}, } @@ -630,8 +636,7 @@ async def test_if_action_after_sunset_with_offset( "trigger": {"platform": "event", "event_type": "test_event"}, "condition": { "condition": "sun", - "after": "sunset", - "after_offset": "+1:00:00", + "options": {"after": "sunset", "after_offset": "+1:00:00"}, }, "action": {"service": "test.automation"}, } @@ -707,8 +712,7 @@ async def test_if_action_after_and_before_during( "trigger": {"platform": "event", "event_type": "test_event"}, "condition": { "condition": "sun", - "after": SUN_EVENT_SUNRISE, - "before": SUN_EVENT_SUNSET, + "options": {"after": SUN_EVENT_SUNRISE, "before": SUN_EVENT_SUNSET}, }, "action": {"service": "test.automation"}, } @@ -812,8 +816,7 @@ async def test_if_action_before_or_after_during( "trigger": {"platform": "event", "event_type": "test_event"}, "condition": { "condition": "sun", - "before": SUN_EVENT_SUNRISE, - "after": SUN_EVENT_SUNSET, + "options": {"before": SUN_EVENT_SUNRISE, "after": SUN_EVENT_SUNSET}, }, "action": {"service": "test.automation"}, } @@ -941,7 +944,10 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( automation.DOMAIN: { "id": "sun", "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "before": SUN_EVENT_SUNRISE}, + "condition": { + "condition": "sun", + "options": {"before": SUN_EVENT_SUNRISE}, + }, "action": {"service": "test.automation"}, } }, @@ -1020,7 +1026,10 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( automation.DOMAIN: { "id": "sun", "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "after": SUN_EVENT_SUNRISE}, + "condition": { + "condition": "sun", + "options": {"after": SUN_EVENT_SUNRISE}, + }, "action": {"service": "test.automation"}, } }, @@ -1099,7 +1108,10 @@ async def test_if_action_before_sunset_no_offset_kotzebue( automation.DOMAIN: { "id": "sun", "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "before": SUN_EVENT_SUNSET}, + "condition": { + "condition": "sun", + "options": {"before": SUN_EVENT_SUNSET}, + }, "action": {"service": "test.automation"}, } }, @@ -1178,7 +1190,10 @@ async def test_if_action_after_sunset_no_offset_kotzebue( automation.DOMAIN: { "id": "sun", "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "after": SUN_EVENT_SUNSET}, + "condition": { + "condition": "sun", + "options": {"after": SUN_EVENT_SUNSET}, + }, "action": {"service": "test.automation"}, } }, diff --git a/tests/components/zone/test_condition.py b/tests/components/zone/test_condition.py index ab78fc90baed..dae76186702e 100644 --- a/tests/components/zone/test_condition.py +++ b/tests/components/zone/test_condition.py @@ -12,8 +12,7 @@ async def test_zone_raises(hass: HomeAssistant) -> None: """Test that zone raises ConditionError on errors.""" config = { "condition": "zone", - "entity_id": "device_tracker.cat", - "zone": "zone.home", + "options": {"entity_id": "device_tracker.cat", "zone": "zone.home"}, } config = cv.CONDITION_SCHEMA(config) config = await condition.async_validate_condition_config(hass, config) @@ -66,8 +65,10 @@ async def test_zone_raises(hass: HomeAssistant) -> None: config = { "condition": "zone", - "entity_id": ["device_tracker.cat", "device_tracker.dog"], - "zone": ["zone.home", "zone.work"], + "options": { + "entity_id": ["device_tracker.cat", "device_tracker.dog"], + "zone": ["zone.home", "zone.work"], + }, } config = cv.CONDITION_SCHEMA(config) config = await condition.async_validate_condition_config(hass, config) @@ -102,8 +103,10 @@ async def test_zone_multiple_entities(hass: HomeAssistant) -> None: { "alias": "Zone Condition", "condition": "zone", - "entity_id": ["device_tracker.person_1", "device_tracker.person_2"], - "zone": "zone.home", + "options": { + "entity_id": ["device_tracker.person_1", "device_tracker.person_2"], + "zone": "zone.home", + }, }, ], } @@ -161,8 +164,10 @@ async def test_multiple_zones(hass: HomeAssistant) -> None: "conditions": [ { "condition": "zone", - "entity_id": "device_tracker.person", - "zone": ["zone.home", "zone.work"], + "options": { + "entity_id": "device_tracker.person", + "zone": ["zone.home", "zone.work"], + }, }, ], } diff --git a/tests/helpers/test_automation.py b/tests/helpers/test_automation.py index 1cd9944aecf8..6e0a76a28ce9 100644 --- a/tests/helpers/test_automation.py +++ b/tests/helpers/test_automation.py @@ -1,10 +1,12 @@ """Test automation helpers.""" import pytest +import voluptuous as vol from homeassistant.helpers.automation import ( get_absolute_description_key, get_relative_description_key, + move_top_level_schema_fields_to_options, ) @@ -34,3 +36,73 @@ 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 + + +@pytest.mark.parametrize( + ("config", "schema_dict", "expected_config"), + [ + ( + { + "platform": "test", + "entity": "sensor.test", + "from": "open", + "to": "closed", + "for": {"hours": 1}, + "attribute": "state", + "value_template": "{{ value_json.val }}", + "extra_field": "extra_value", + }, + {}, + { + "platform": "test", + "entity": "sensor.test", + "from": "open", + "to": "closed", + "for": {"hours": 1}, + "attribute": "state", + "value_template": "{{ value_json.val }}", + "extra_field": "extra_value", + "options": {}, + }, + ), + ( + { + "platform": "test", + "entity": "sensor.test", + "from": "open", + "to": "closed", + "for": {"hours": 1}, + "attribute": "state", + "value_template": "{{ value_json.val }}", + "extra_field": "extra_value", + }, + { + vol.Required("entity"): str, + vol.Optional("from"): str, + vol.Optional("to"): str, + vol.Optional("for"): dict, + vol.Optional("attribute"): str, + vol.Optional("value_template"): str, + }, + { + "platform": "test", + "extra_field": "extra_value", + "options": { + "entity": "sensor.test", + "from": "open", + "to": "closed", + "for": {"hours": 1}, + "attribute": "state", + "value_template": "{{ value_json.val }}", + }, + }, + ), + ], +) +async def test_move_schema_fields_to_options( + config, schema_dict, expected_config +) -> None: + """Test moving schema fields to options.""" + assert ( + move_top_level_schema_fields_to_options(config, schema_dict) == expected_config + ) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 260ef86023d7..e8e334d2ab68 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -32,6 +32,13 @@ from homeassistant.helpers import ( entity_registry as er, trace, ) +from homeassistant.helpers.automation import move_top_level_schema_fields_to_options +from homeassistant.helpers.condition import ( + Condition, + ConditionCheckerType, + ConditionConfig, + async_validate_condition_config, +) from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType from homeassistant.loader import Integration, async_get_integration @@ -2105,12 +2112,9 @@ async def test_platform_async_get_conditions(hass: HomeAssistant) -> None: async def test_platform_multiple_conditions(hass: HomeAssistant) -> None: """Test a condition platform with multiple conditions.""" - class MockCondition(condition.Condition): + class MockCondition(Condition): """Mock condition.""" - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: - """Initialize condition.""" - @classmethod async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType @@ -2118,23 +2122,24 @@ async def test_platform_multiple_conditions(hass: HomeAssistant) -> None: """Validate config.""" return config + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Initialize condition.""" + class MockCondition1(MockCondition): """Mock condition 1.""" - async def async_get_checker(self) -> condition.ConditionCheckerType: + async def async_get_checker(self) -> ConditionCheckerType: """Evaluate state based on configuration.""" return lambda hass, vars: True class MockCondition2(MockCondition): """Mock condition 2.""" - async def async_get_checker(self) -> condition.ConditionCheckerType: + async def async_get_checker(self) -> ConditionCheckerType: """Evaluate state based on configuration.""" return lambda hass, vars: False - async def async_get_conditions( - hass: HomeAssistant, - ) -> dict[str, type[condition.Condition]]: + async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: return { "_": MockCondition1, "cond_2": MockCondition2, @@ -2148,12 +2153,12 @@ async def test_platform_multiple_conditions(hass: HomeAssistant) -> None: config_1 = {CONF_CONDITION: "test"} config_2 = {CONF_CONDITION: "test.cond_2"} config_3 = {CONF_CONDITION: "test.unknown_cond"} - assert await condition.async_validate_condition_config(hass, config_1) == config_1 - assert await condition.async_validate_condition_config(hass, config_2) == config_2 + assert await async_validate_condition_config(hass, config_1) == config_1 + assert await async_validate_condition_config(hass, config_2) == config_2 with pytest.raises( vol.Invalid, match="Invalid condition 'test.unknown_cond' specified" ): - await condition.async_validate_condition_config(hass, config_3) + await async_validate_condition_config(hass, config_3) cond_func = await condition.async_from_config(hass, config_1) assert cond_func(hass, {}) is True @@ -2165,6 +2170,74 @@ async def test_platform_multiple_conditions(hass: HomeAssistant) -> None: await condition.async_from_config(hass, config_3) +async def test_platform_migrate_trigger(hass: HomeAssistant) -> None: + """Test a condition platform with a migration.""" + + OPTIONS_SCHEMA_DICT = { + vol.Required("option_1"): str, + vol.Optional("option_2"): int, + } + + class MockCondition(Condition): + """Mock condition.""" + + @classmethod + async def async_validate_complete_config( + cls, hass: HomeAssistant, complete_config: ConfigType + ) -> ConfigType: + """Validate complete config.""" + complete_config = move_top_level_schema_fields_to_options( + complete_config, OPTIONS_SCHEMA_DICT + ) + return await super().async_validate_complete_config(hass, complete_config) + + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return config + + async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + return { + "_": MockCondition, + } + + mock_integration(hass, MockModule("test")) + mock_platform( + hass, "test.condition", Mock(async_get_conditions=async_get_conditions) + ) + + config_1 = { + "condition": "test", + "option_1": "value_1", + "option_2": 2, + } + config_2 = { + "condition": "test", + "option_1": "value_1", + } + config_1_migrated = { + "condition": "test", + "options": {"option_1": "value_1", "option_2": 2}, + } + config_2_migrated = { + "condition": "test", + "options": {"option_1": "value_1"}, + } + + assert await async_validate_condition_config(hass, config_1) == config_1_migrated + assert await async_validate_condition_config(hass, config_2) == config_2_migrated + assert ( + await async_validate_condition_config(hass, config_1_migrated) + == config_1_migrated + ) + assert ( + await async_validate_condition_config(hass, config_2_migrated) + == config_2_migrated + ) + + @pytest.mark.parametrize("enabled_value", [True, "{{ 1 == 1 }}"]) async def test_enabled_condition( hass: HomeAssistant, enabled_value: bool | str diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index d28d0bc1a1c9..0a271057ad5f 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -19,6 +19,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import trigger +from homeassistant.helpers.automation import move_top_level_schema_fields_to_options from homeassistant.helpers.trigger import ( DATA_PLUGGABLE_ACTIONS, PluggableAction, @@ -29,7 +30,6 @@ from homeassistant.helpers.trigger import ( _async_get_trigger_platform, async_initialize_triggers, async_validate_trigger_config, - move_top_level_schema_fields_to_options, ) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import Integration, async_get_integration @@ -449,76 +449,6 @@ async def test_pluggable_action( assert not plug_2 -@pytest.mark.parametrize( - ("config", "schema_dict", "expected_config"), - [ - ( - { - "platform": "test", - "entity": "sensor.test", - "from": "open", - "to": "closed", - "for": {"hours": 1}, - "attribute": "state", - "value_template": "{{ value_json.val }}", - "extra_field": "extra_value", - }, - {}, - { - "platform": "test", - "entity": "sensor.test", - "from": "open", - "to": "closed", - "for": {"hours": 1}, - "attribute": "state", - "value_template": "{{ value_json.val }}", - "extra_field": "extra_value", - "options": {}, - }, - ), - ( - { - "platform": "test", - "entity": "sensor.test", - "from": "open", - "to": "closed", - "for": {"hours": 1}, - "attribute": "state", - "value_template": "{{ value_json.val }}", - "extra_field": "extra_value", - }, - { - vol.Required("entity"): str, - vol.Optional("from"): str, - vol.Optional("to"): str, - vol.Optional("for"): dict, - vol.Optional("attribute"): str, - vol.Optional("value_template"): str, - }, - { - "platform": "test", - "extra_field": "extra_value", - "options": { - "entity": "sensor.test", - "from": "open", - "to": "closed", - "for": {"hours": 1}, - "attribute": "state", - "value_template": "{{ value_json.val }}", - }, - }, - ), - ], -) -async def test_move_schema_fields_to_options( - config, schema_dict, expected_config -) -> None: - """Test moving schema fields to options.""" - assert ( - move_top_level_schema_fields_to_options(config, schema_dict) == expected_config - ) - - async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: """Test a trigger platform with multiple trigger.""" From 9db973217f401892969804d0bd936ad130f5a257 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Thu, 25 Sep 2025 11:18:24 -0400 Subject: [PATCH 156/189] Fix incorrect Roborock test (#152980) --- tests/components/roborock/test_coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/roborock/test_coordinator.py b/tests/components/roborock/test_coordinator.py index 7da19e9418cb..315ab14bdb50 100644 --- a/tests/components/roborock/test_coordinator.py +++ b/tests/components/roborock/test_coordinator.py @@ -152,7 +152,7 @@ async def test_no_maps( return_value=prop, ), patch( - "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_multi_maps_list", + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_multi_maps_list", return_value=MultiMapsList( max_multi_map=1, max_bak_map=1, multi_map_count=0, map_info=[] ), From 0c5e12571ab50e0951482ef809f43b27e5152d73 Mon Sep 17 00:00:00 2001 From: Daniel Potthast Date: Thu, 25 Sep 2025 17:20:43 +0200 Subject: [PATCH 157/189] Update mvglive component (#146479) Co-authored-by: Erik Montnemery --- .../components/mvglive/manifest.json | 6 +- homeassistant/components/mvglive/sensor.py | 204 ++++++++++-------- requirements_all.txt | 3 + 3 files changed, 122 insertions(+), 91 deletions(-) diff --git a/homeassistant/components/mvglive/manifest.json b/homeassistant/components/mvglive/manifest.json index 2c4e6a7e735a..8058c602dc4d 100644 --- a/homeassistant/components/mvglive/manifest.json +++ b/homeassistant/components/mvglive/manifest.json @@ -2,10 +2,8 @@ "domain": "mvglive", "name": "MVG", "codeowners": [], - "disabled": "This integration is disabled because it uses non-open source code to operate.", "documentation": "https://www.home-assistant.io/integrations/mvglive", "iot_class": "cloud_polling", - "loggers": ["MVGLive"], - "quality_scale": "legacy", - "requirements": ["PyMVGLive==1.1.4"] + "loggers": ["MVG"], + "requirements": ["mvg==1.4.0"] } diff --git a/homeassistant/components/mvglive/sensor.py b/homeassistant/components/mvglive/sensor.py index d8b435177118..031ec164ecd7 100644 --- a/homeassistant/components/mvglive/sensor.py +++ b/homeassistant/components/mvglive/sensor.py @@ -1,13 +1,14 @@ """Support for departure information for public transport in Munich.""" -# mypy: ignore-errors from __future__ import annotations +from collections.abc import Mapping from copy import deepcopy from datetime import timedelta import logging +from typing import Any -import MVGLive +from mvg import MvgApi, MvgApiError, TransportType import voluptuous as vol from homeassistant.components.sensor import ( @@ -19,6 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -44,53 +46,55 @@ ICONS = { "SEV": "mdi:checkbox-blank-circle-outline", "-": "mdi:clock", } -ATTRIBUTION = "Data provided by MVG-live.de" + +ATTRIBUTION = "Data provided by mvg.de" SCAN_INTERVAL = timedelta(seconds=30) -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_NEXT_DEPARTURE): [ - { - vol.Required(CONF_STATION): cv.string, - vol.Optional(CONF_DESTINATIONS, default=[""]): cv.ensure_list_csv, - vol.Optional(CONF_DIRECTIONS, default=[""]): cv.ensure_list_csv, - vol.Optional(CONF_LINES, default=[""]): cv.ensure_list_csv, - vol.Optional( - CONF_PRODUCTS, default=DEFAULT_PRODUCT - ): cv.ensure_list_csv, - vol.Optional(CONF_TIMEOFFSET, default=0): cv.positive_int, - vol.Optional(CONF_NUMBER, default=1): cv.positive_int, - vol.Optional(CONF_NAME): cv.string, - } - ] - } +PLATFORM_SCHEMA = vol.All( + cv.deprecated(CONF_DIRECTIONS), + SENSOR_PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_NEXT_DEPARTURE): [ + { + vol.Required(CONF_STATION): cv.string, + vol.Optional(CONF_DESTINATIONS, default=[""]): cv.ensure_list_csv, + vol.Optional(CONF_DIRECTIONS, default=[""]): cv.ensure_list_csv, + vol.Optional(CONF_LINES, default=[""]): cv.ensure_list_csv, + vol.Optional( + CONF_PRODUCTS, default=DEFAULT_PRODUCT + ): cv.ensure_list_csv, + vol.Optional(CONF_TIMEOFFSET, default=0): cv.positive_int, + vol.Optional(CONF_NUMBER, default=1): cv.positive_int, + vol.Optional(CONF_NAME): cv.string, + } + ] + } + ), ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the MVGLive sensor.""" - add_entities( - ( - MVGLiveSensor( - nextdeparture.get(CONF_STATION), - nextdeparture.get(CONF_DESTINATIONS), - nextdeparture.get(CONF_DIRECTIONS), - nextdeparture.get(CONF_LINES), - nextdeparture.get(CONF_PRODUCTS), - nextdeparture.get(CONF_TIMEOFFSET), - nextdeparture.get(CONF_NUMBER), - nextdeparture.get(CONF_NAME), - ) - for nextdeparture in config[CONF_NEXT_DEPARTURE] - ), - True, - ) + sensors = [ + MVGLiveSensor( + hass, + nextdeparture.get(CONF_STATION), + nextdeparture.get(CONF_DESTINATIONS), + nextdeparture.get(CONF_LINES), + nextdeparture.get(CONF_PRODUCTS), + nextdeparture.get(CONF_TIMEOFFSET), + nextdeparture.get(CONF_NUMBER), + nextdeparture.get(CONF_NAME), + ) + for nextdeparture in config[CONF_NEXT_DEPARTURE] + ] + add_entities(sensors, True) class MVGLiveSensor(SensorEntity): @@ -100,38 +104,38 @@ class MVGLiveSensor(SensorEntity): def __init__( self, - station, + hass: HomeAssistant, + station_name, destinations, - directions, lines, products, timeoffset, number, name, - ): + ) -> None: """Initialize the sensor.""" - self._station = station self._name = name + self._station_name = station_name self.data = MVGLiveData( - station, destinations, directions, lines, products, timeoffset, number + hass, station_name, destinations, lines, products, timeoffset, number ) self._state = None self._icon = ICONS["-"] @property - def name(self): + def name(self) -> str | None: """Return the name of the sensor.""" if self._name: return self._name - return self._station + return self._station_name @property - def native_value(self): + def native_value(self) -> str | None: """Return the next departure time.""" return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes.""" if not (dep := self.data.departures): return None @@ -140,88 +144,114 @@ class MVGLiveSensor(SensorEntity): return attr @property - def icon(self): + def icon(self) -> str | None: """Icon to use in the frontend, if any.""" return self._icon @property - def native_unit_of_measurement(self): + def native_unit_of_measurement(self) -> str | None: """Return the unit this state is expressed in.""" return UnitOfTime.MINUTES - def update(self) -> None: + async def async_update(self) -> None: """Get the latest data and update the state.""" - self.data.update() + await self.data.update() if not self.data.departures: - self._state = "-" + self._state = None self._icon = ICONS["-"] else: - self._state = self.data.departures[0].get("time", "-") - self._icon = ICONS[self.data.departures[0].get("product", "-")] + self._state = self.data.departures[0].get("time_in_mins", "-") + self._icon = self.data.departures[0].get("icon", ICONS["-"]) + + +def _get_minutes_until_departure(departure_time: int) -> int: + """Calculate the time difference in minutes between the current time and a given departure time. + + Args: + departure_time: Unix timestamp of the departure time, in seconds. + + Returns: + The time difference in minutes, as an integer. + + """ + current_time = dt_util.utcnow() + departure_datetime = dt_util.utc_from_timestamp(departure_time) + time_difference = (departure_datetime - current_time).total_seconds() + return int(time_difference / 60.0) class MVGLiveData: - """Pull data from the mvg-live.de web page.""" + """Pull data from the mvg.de web page.""" def __init__( - self, station, destinations, directions, lines, products, timeoffset, number - ): + self, + hass: HomeAssistant, + station_name, + destinations, + lines, + products, + timeoffset, + number, + ) -> None: """Initialize the sensor.""" - self._station = station + self._hass = hass + self._station_name = station_name + self._station_id = None self._destinations = destinations - self._directions = directions self._lines = lines self._products = products self._timeoffset = timeoffset self._number = number - self._include_ubahn = "U-Bahn" in self._products - self._include_tram = "Tram" in self._products - self._include_bus = "Bus" in self._products - self._include_sbahn = "S-Bahn" in self._products - self.mvg = MVGLive.MVGLive() - self.departures = [] + self.departures: list[dict[str, Any]] = [] - def update(self): + async def update(self): """Update the connection data.""" + if self._station_id is None: + try: + station = await MvgApi.station_async(self._station_name) + self._station_id = station["id"] + except MvgApiError as err: + _LOGGER.error( + "Failed to resolve station %s: %s", self._station_name, err + ) + self.departures = [] + return + try: - _departures = self.mvg.getlivedata( - station=self._station, - timeoffset=self._timeoffset, - ubahn=self._include_ubahn, - tram=self._include_tram, - bus=self._include_bus, - sbahn=self._include_sbahn, + _departures = await MvgApi.departures_async( + station_id=self._station_id, + offset=self._timeoffset, + limit=self._number, + transport_types=[ + transport_type + for transport_type in TransportType + if transport_type.value[0] in self._products + ] + if self._products + else None, ) except ValueError: self.departures = [] _LOGGER.warning("Returned data not understood") return self.departures = [] - for i, _departure in enumerate(_departures): - # find the first departure meeting the criteria + for _departure in _departures: if ( "" not in self._destinations[:1] and _departure["destination"] not in self._destinations ): continue - if ( - "" not in self._directions[:1] - and _departure["direction"] not in self._directions - ): + if "" not in self._lines[:1] and _departure["line"] not in self._lines: continue - if "" not in self._lines[:1] and _departure["linename"] not in self._lines: + time_to_departure = _get_minutes_until_departure(_departure["time"]) + + if time_to_departure < self._timeoffset: continue - if _departure["time"] < self._timeoffset: - continue - - # now select the relevant data _nextdep = {} - for k in ("destination", "linename", "time", "direction", "product"): + for k in ("destination", "line", "type", "cancelled", "icon"): _nextdep[k] = _departure.get(k, "") - _nextdep["time"] = int(_nextdep["time"]) + _nextdep["time_in_mins"] = time_to_departure self.departures.append(_nextdep) - if i == self._number - 1: - break diff --git a/requirements_all.txt b/requirements_all.txt index cf251a0784ce..e7eb58bbb205 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1499,6 +1499,9 @@ mutagen==1.47.0 # homeassistant.components.mutesync mutesync==0.0.1 +# homeassistant.components.mvglive +mvg==1.4.0 + # homeassistant.components.permobil mypermobil==0.1.8 From 7ee31f088465b75a106c1448127f992560f60b64 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 25 Sep 2025 17:57:30 +0200 Subject: [PATCH 158/189] Bump pySmartThings to 3.3.0 (#152977) --- 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 951d1372a699..96c6d94da4f9 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.9"] + "requirements": ["pysmartthings==3.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e7eb58bbb205..b580de8f356a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2384,7 +2384,7 @@ pysmappee==0.2.29 pysmarlaapi==0.9.2 # homeassistant.components.smartthings -pysmartthings==3.2.9 +pysmartthings==3.3.0 # homeassistant.components.smarty pysmarty2==0.10.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c6183a4199e..9abe779c7789 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1987,7 +1987,7 @@ pysmappee==0.2.29 pysmarlaapi==0.9.2 # homeassistant.components.smartthings -pysmartthings==3.2.9 +pysmartthings==3.3.0 # homeassistant.components.smarty pysmarty2==0.10.3 From 159c7fbfd15c9f67c51a96189bfb32c4b5a5296e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 25 Sep 2025 18:29:26 +0200 Subject: [PATCH 159/189] Correct filter of target selector in sonos services (#152972) --- homeassistant/components/sonos/services.yaml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sonos/services.yaml b/homeassistant/components/sonos/services.yaml index 897064288997..5d596c5679fe 100644 --- a/homeassistant/components/sonos/services.yaml +++ b/homeassistant/components/sonos/services.yaml @@ -24,8 +24,9 @@ restore: set_sleep_timer: target: - device: + entity: integration: sonos + domain: media_player fields: sleep_time: selector: @@ -36,13 +37,15 @@ set_sleep_timer: clear_sleep_timer: target: - device: + entity: integration: sonos + domain: media_player play_queue: target: - device: + entity: integration: sonos + domain: media_player fields: queue_position: selector: @@ -53,8 +56,9 @@ play_queue: remove_from_queue: target: - device: + entity: integration: sonos + domain: media_player fields: queue_position: selector: @@ -71,8 +75,9 @@ get_queue: update_alarm: target: - device: + entity: integration: sonos + domain: media_player fields: alarm_id: required: true From eb38837a8cff410a91611f725512b102923370dc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 25 Sep 2025 18:30:05 +0200 Subject: [PATCH 160/189] Replace target selector with device selector in fully_kiosk services (#152959) Co-authored-by: Franck Nijhof Co-authored-by: Norbert Rittel --- .../components/fully_kiosk/services.yaml | 24 ++++++++++++------- .../components/fully_kiosk/strings.json | 12 ++++++++++ 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/fully_kiosk/services.yaml b/homeassistant/components/fully_kiosk/services.yaml index 7784996da9be..9cfc91295ede 100644 --- a/homeassistant/components/fully_kiosk/services.yaml +++ b/homeassistant/components/fully_kiosk/services.yaml @@ -1,8 +1,10 @@ load_url: - target: - device: - integration: fully_kiosk fields: + device_id: + required: true + selector: + device: + integration: fully_kiosk url: example: "https://home-assistant.io" required: true @@ -10,10 +12,12 @@ load_url: text: set_config: - target: - device: - integration: fully_kiosk fields: + device_id: + required: true + selector: + device: + integration: fully_kiosk key: example: "motionSensitivity" required: true @@ -26,12 +30,14 @@ set_config: text: start_application: - target: - device: - integration: fully_kiosk fields: application: example: "de.ozerov.fully" required: true selector: text: + device_id: + required: true + selector: + device: + integration: fully_kiosk diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index fd7eaecd4465..785124575ba8 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -147,6 +147,10 @@ "name": "Load URL", "description": "Loads a URL on Fully Kiosk Browser.", "fields": { + "device_id": { + "name": "Device ID", + "description": "The target device for this action." + }, "url": { "name": "[%key:common::config_flow::data::url%]", "description": "URL to load." @@ -157,6 +161,10 @@ "name": "Set configuration", "description": "Sets a configuration parameter on Fully Kiosk Browser.", "fields": { + "device_id": { + "name": "%key:component::fully_kiosk::services::load_url::fields::device_id::name%", + "description": "%key:component::fully_kiosk::services::load_url::fields::device_id::description%" + }, "key": { "name": "Key", "description": "Configuration parameter to set." @@ -174,6 +182,10 @@ "application": { "name": "Application", "description": "Package name of the application to start." + }, + "device_id": { + "name": "%key:component::fully_kiosk::services::load_url::fields::device_id::name%", + "description": "%key:component::fully_kiosk::services::load_url::fields::device_id::description%" } } } From 1c12d2b8cd0ca0c88aac44fd387df5b494be3e3f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 25 Sep 2025 18:30:47 +0200 Subject: [PATCH 161/189] Bump accuweather to version 4.2.2 (#152965) --- homeassistant/components/accuweather/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 09ea76d022dc..11f927c6aeb6 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["accuweather"], - "requirements": ["accuweather==4.2.1"] + "requirements": ["accuweather==4.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index b580de8f356a..24f911f84245 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -131,7 +131,7 @@ TwitterAPI==2.7.12 WSDiscovery==2.1.2 # homeassistant.components.accuweather -accuweather==4.2.1 +accuweather==4.2.2 # homeassistant.components.adax adax==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9abe779c7789..868386b0f0f6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -119,7 +119,7 @@ Tami4EdgeAPI==3.0 WSDiscovery==2.1.2 # homeassistant.components.accuweather -accuweather==4.2.1 +accuweather==4.2.2 # homeassistant.components.adax adax==0.4.0 From 47df73b18ff6b0ddb4526184f10f68b9e0ab98e5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 25 Sep 2025 18:32:12 +0200 Subject: [PATCH 162/189] Remove device filter from target selector in google_mail services (#152968) --- homeassistant/components/google_mail/services.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/google_mail/services.yaml b/homeassistant/components/google_mail/services.yaml index 9ce1c41f27a3..1be14b8fac23 100644 --- a/homeassistant/components/google_mail/services.yaml +++ b/homeassistant/components/google_mail/services.yaml @@ -1,7 +1,5 @@ set_vacation: target: - device: - integration: google_mail entity: integration: google_mail fields: From 88016d96d440637f55eec59772f1efeceed470ff Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 25 Sep 2025 18:41:54 +0200 Subject: [PATCH 163/189] Remove device and entity filter from target selector in homeassistant services (#152969) --- homeassistant/components/homeassistant/services.yaml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index 372f4fa9955f..b928ff0b8510 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -32,15 +32,12 @@ set_location: stop: toggle: target: - entity: {} turn_on: target: - entity: {} turn_off: target: - entity: {} update_entity: fields: @@ -53,8 +50,6 @@ update_entity: reload_custom_templates: reload_config_entry: target: - entity: {} - device: {} fields: entry_id: advanced: true From 8f99c3f64a420eaa45cedf1469d140bb1298ebb7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 25 Sep 2025 18:45:32 +0200 Subject: [PATCH 164/189] Remove device filter from target selector in lyric services (#152970) --- homeassistant/components/lyric/services.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/lyric/services.yaml b/homeassistant/components/lyric/services.yaml index c3c4bc640bf5..3dd300f48ad3 100644 --- a/homeassistant/components/lyric/services.yaml +++ b/homeassistant/components/lyric/services.yaml @@ -1,7 +1,5 @@ set_hold_time: target: - device: - integration: lyric entity: integration: lyric domain: climate From bc886963397b19d6280b8e60841eaa75c96c49bd Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 25 Sep 2025 18:59:53 +0200 Subject: [PATCH 165/189] Remove deprecated sensors and update remaning for Alexa Devices (#151230) --- .../components/alexa_devices/binary_sensor.py | 74 +++++++++---------- .../components/alexa_devices/config_flow.py | 4 +- .../components/alexa_devices/coordinator.py | 2 +- .../components/alexa_devices/diagnostics.py | 4 +- .../components/alexa_devices/icons.json | 40 ---------- .../components/alexa_devices/manifest.json | 2 +- .../components/alexa_devices/sensor.py | 13 ++++ .../components/alexa_devices/strings.json | 20 ----- .../components/alexa_devices/switch.py | 34 +++++++-- .../components/alexa_devices/utils.py | 25 ++++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/alexa_devices/const.py | 17 ++--- .../snapshots/test_binary_sensor.ambr | 48 ------------ .../snapshots/test_diagnostics.ambr | 26 +++++-- .../snapshots/test_services.ambr | 24 ++++-- .../alexa_devices/snapshots/test_switch.ambr | 2 +- tests/components/alexa_devices/test_sensor.py | 30 +++++++- tests/components/alexa_devices/test_switch.py | 50 ++++++++----- tests/components/alexa_devices/test_utils.py | 40 ++++++++++ 20 files changed, 250 insertions(+), 209 deletions(-) diff --git a/homeassistant/components/alexa_devices/binary_sensor.py b/homeassistant/components/alexa_devices/binary_sensor.py index 410ea4555e24..296f4c417f02 100644 --- a/homeassistant/components/alexa_devices/binary_sensor.py +++ b/homeassistant/components/alexa_devices/binary_sensor.py @@ -10,6 +10,7 @@ from aioamazondevices.api import AmazonDevice from aioamazondevices.const import SENSOR_STATE_OFF from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, @@ -20,6 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AmazonConfigEntry from .entity import AmazonEntity +from .utils import async_update_unique_id # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -31,6 +33,7 @@ class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription): is_on_fn: Callable[[AmazonDevice, str], bool] is_supported: Callable[[AmazonDevice, str], bool] = lambda device, key: True + is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: True BINARY_SENSORS: Final = ( @@ -41,46 +44,15 @@ BINARY_SENSORS: Final = ( is_on_fn=lambda device, _: device.online, ), AmazonBinarySensorEntityDescription( - key="bluetooth", - entity_category=EntityCategory.DIAGNOSTIC, - translation_key="bluetooth", - is_on_fn=lambda device, _: device.bluetooth_state, - ), - AmazonBinarySensorEntityDescription( - key="babyCryDetectionState", - translation_key="baby_cry_detection", - is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), - is_supported=lambda device, key: device.sensors.get(key) is not None, - ), - AmazonBinarySensorEntityDescription( - key="beepingApplianceDetectionState", - translation_key="beeping_appliance_detection", - is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), - is_supported=lambda device, key: device.sensors.get(key) is not None, - ), - AmazonBinarySensorEntityDescription( - key="coughDetectionState", - translation_key="cough_detection", - is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), - is_supported=lambda device, key: device.sensors.get(key) is not None, - ), - AmazonBinarySensorEntityDescription( - key="dogBarkDetectionState", - translation_key="dog_bark_detection", - is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), - is_supported=lambda device, key: device.sensors.get(key) is not None, - ), - AmazonBinarySensorEntityDescription( - key="humanPresenceDetectionState", + key="detectionState", device_class=BinarySensorDeviceClass.MOTION, - is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), - is_supported=lambda device, key: device.sensors.get(key) is not None, - ), - AmazonBinarySensorEntityDescription( - key="waterSoundsDetectionState", - translation_key="water_sounds_detection", - is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), + is_on_fn=lambda device, key: bool( + device.sensors[key].value != SENSOR_STATE_OFF + ), is_supported=lambda device, key: device.sensors.get(key) is not None, + is_available_fn=lambda device, key: ( + device.online and device.sensors[key].error is False + ), ), ) @@ -94,6 +66,22 @@ async def async_setup_entry( coordinator = entry.runtime_data + # Replace unique id for "detectionState" binary sensor + await async_update_unique_id( + hass, + coordinator, + BINARY_SENSOR_DOMAIN, + "humanPresenceDetectionState", + "detectionState", + ) + + async_add_entities( + AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc) + for sensor_desc in BINARY_SENSORS + for serial_num in coordinator.data + if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key) + ) + known_devices: set[str] = set() def _check_device() -> None: @@ -125,3 +113,13 @@ class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity): return self.entity_description.is_on_fn( self.device, self.entity_description.key ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + self.entity_description.is_available_fn( + self.device, self.entity_description.key + ) + and super().available + ) diff --git a/homeassistant/components/alexa_devices/config_flow.py b/homeassistant/components/alexa_devices/config_flow.py index a3bcce1965b2..e863f137f70a 100644 --- a/homeassistant/components/alexa_devices/config_flow.py +++ b/homeassistant/components/alexa_devices/config_flow.py @@ -64,7 +64,7 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): data = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" - except (CannotAuthenticate, TypeError): + except CannotAuthenticate: errors["base"] = "invalid_auth" except CannotRetrieveData: errors["base"] = "cannot_retrieve_data" @@ -112,7 +112,7 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): ) except CannotConnect: errors["base"] = "cannot_connect" - except (CannotAuthenticate, TypeError): + except CannotAuthenticate: errors["base"] = "invalid_auth" except CannotRetrieveData: errors["base"] = "cannot_retrieve_data" diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py index 3b14324fdb68..6ce21aa22163 100644 --- a/homeassistant/components/alexa_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -68,7 +68,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): translation_key="cannot_retrieve_data_with_error", translation_placeholders={"error": repr(err)}, ) from err - except (CannotAuthenticate, TypeError) as err: + except CannotAuthenticate as err: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", diff --git a/homeassistant/components/alexa_devices/diagnostics.py b/homeassistant/components/alexa_devices/diagnostics.py index 0c4cb7944168..938a20fb2189 100644 --- a/homeassistant/components/alexa_devices/diagnostics.py +++ b/homeassistant/components/alexa_devices/diagnostics.py @@ -60,7 +60,5 @@ def build_device_data(device: AmazonDevice) -> dict[str, Any]: "online": device.online, "serial number": device.serial_number, "software version": device.software_version, - "do not disturb": device.do_not_disturb, - "response style": device.response_style, - "bluetooth state": device.bluetooth_state, + "sensors": device.sensors, } diff --git a/homeassistant/components/alexa_devices/icons.json b/homeassistant/components/alexa_devices/icons.json index bedd4af17342..f9e8de057d02 100644 --- a/homeassistant/components/alexa_devices/icons.json +++ b/homeassistant/components/alexa_devices/icons.json @@ -1,44 +1,4 @@ { - "entity": { - "binary_sensor": { - "bluetooth": { - "default": "mdi:bluetooth-off", - "state": { - "on": "mdi:bluetooth" - } - }, - "baby_cry_detection": { - "default": "mdi:account-voice-off", - "state": { - "on": "mdi:account-voice" - } - }, - "beeping_appliance_detection": { - "default": "mdi:bell-off", - "state": { - "on": "mdi:bell-ring" - } - }, - "cough_detection": { - "default": "mdi:blur-off", - "state": { - "on": "mdi:blur" - } - }, - "dog_bark_detection": { - "default": "mdi:dog-side-off", - "state": { - "on": "mdi:dog-side" - } - }, - "water_sounds_detection": { - "default": "mdi:water-pump-off", - "state": { - "on": "mdi:water-pump" - } - } - } - }, "services": { "send_sound": { "service": "mdi:cast-audio" diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 437c11e0a4c1..14b2ddf90d96 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==6.0.0"] + "requirements": ["aioamazondevices==6.2.6"] } diff --git a/homeassistant/components/alexa_devices/sensor.py b/homeassistant/components/alexa_devices/sensor.py index 1a863e87c1a7..e6dbc251b950 100644 --- a/homeassistant/components/alexa_devices/sensor.py +++ b/homeassistant/components/alexa_devices/sensor.py @@ -31,6 +31,9 @@ class AmazonSensorEntityDescription(SensorEntityDescription): """Amazon Devices sensor entity description.""" native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None + is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: ( + device.online and device.sensors[key].error is False + ) SENSORS: Final = ( @@ -99,3 +102,13 @@ class AmazonSensorEntity(AmazonEntity, SensorEntity): def native_value(self) -> StateType: """Return the state of the sensor.""" return self.device.sensors[self.entity_description.key].value + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + self.entity_description.is_available_fn( + self.device, self.entity_description.key + ) + and super().available + ) diff --git a/homeassistant/components/alexa_devices/strings.json b/homeassistant/components/alexa_devices/strings.json index 8e56a7a51b61..f6b850f0920a 100644 --- a/homeassistant/components/alexa_devices/strings.json +++ b/homeassistant/components/alexa_devices/strings.json @@ -58,26 +58,6 @@ } }, "entity": { - "binary_sensor": { - "bluetooth": { - "name": "Bluetooth" - }, - "baby_cry_detection": { - "name": "Baby crying" - }, - "beeping_appliance_detection": { - "name": "Beeping appliance" - }, - "cough_detection": { - "name": "Coughing" - }, - "dog_bark_detection": { - "name": "Dog barking" - }, - "water_sounds_detection": { - "name": "Water sounds" - } - }, "notify": { "speak": { "name": "Speak" diff --git a/homeassistant/components/alexa_devices/switch.py b/homeassistant/components/alexa_devices/switch.py index 138013666c6e..2994ab777514 100644 --- a/homeassistant/components/alexa_devices/switch.py +++ b/homeassistant/components/alexa_devices/switch.py @@ -8,13 +8,17 @@ from typing import TYPE_CHECKING, Any, Final from aioamazondevices.api import AmazonDevice -from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AmazonConfigEntry from .entity import AmazonEntity -from .utils import alexa_api_call +from .utils import alexa_api_call, async_update_unique_id PARALLEL_UPDATES = 1 @@ -24,16 +28,17 @@ class AmazonSwitchEntityDescription(SwitchEntityDescription): """Alexa Devices switch entity description.""" is_on_fn: Callable[[AmazonDevice], bool] - subkey: str + is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: ( + device.online and device.sensors[key].error is False + ) method: str SWITCHES: Final = ( AmazonSwitchEntityDescription( - key="do_not_disturb", - subkey="AUDIO_PLAYER", + key="dnd", translation_key="do_not_disturb", - is_on_fn=lambda _device: _device.do_not_disturb, + is_on_fn=lambda device: bool(device.sensors["dnd"].value), method="set_do_not_disturb", ), ) @@ -48,6 +53,11 @@ async def async_setup_entry( coordinator = entry.runtime_data + # Replace unique id for "DND" switch and remove from Speaker Group + await async_update_unique_id( + hass, coordinator, SWITCH_DOMAIN, "do_not_disturb", "dnd" + ) + known_devices: set[str] = set() def _check_device() -> None: @@ -59,7 +69,7 @@ async def async_setup_entry( AmazonSwitchEntity(coordinator, serial_num, switch_desc) for switch_desc in SWITCHES for serial_num in new_devices - if switch_desc.subkey in coordinator.data[serial_num].capabilities + if switch_desc.key in coordinator.data[serial_num].sensors ) _check_device() @@ -94,3 +104,13 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity): def is_on(self) -> bool: """Return True if switch is on.""" return self.entity_description.is_on_fn(self.device) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + self.entity_description.is_available_fn( + self.device, self.entity_description.key + ) + and super().available + ) diff --git a/homeassistant/components/alexa_devices/utils.py b/homeassistant/components/alexa_devices/utils.py index 437b681413b6..f8898aa5fe46 100644 --- a/homeassistant/components/alexa_devices/utils.py +++ b/homeassistant/components/alexa_devices/utils.py @@ -6,9 +6,12 @@ from typing import Any, Concatenate from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.entity_registry as er -from .const import DOMAIN +from .const import _LOGGER, DOMAIN +from .coordinator import AmazonDevicesCoordinator from .entity import AmazonEntity @@ -38,3 +41,23 @@ def alexa_api_call[_T: AmazonEntity, **_P]( ) from err return cmd_wrapper + + +async def async_update_unique_id( + hass: HomeAssistant, + coordinator: AmazonDevicesCoordinator, + domain: str, + old_key: str, + new_key: str, +) -> None: + """Update unique id for entities created with old format.""" + entity_registry = er.async_get(hass) + + for serial_num in coordinator.data: + unique_id = f"{serial_num}-{old_key}" + if entity_id := entity_registry.async_get_entity_id(domain, DOMAIN, unique_id): + _LOGGER.debug("Updating unique_id for %s", entity_id) + new_unique_id = unique_id.replace(old_key, new_key) + + # Update the registry with the new unique_id + entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id) diff --git a/requirements_all.txt b/requirements_all.txt index 24f911f84245..c2f4528d1aad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.1 # homeassistant.components.alexa_devices -aioamazondevices==6.0.0 +aioamazondevices==6.2.6 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 868386b0f0f6..48d8d367b3ae 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.1 # homeassistant.components.alexa_devices -aioamazondevices==6.0.0 +aioamazondevices==6.2.6 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/tests/components/alexa_devices/const.py b/tests/components/alexa_devices/const.py index d078e92199ed..05a6ff587196 100644 --- a/tests/components/alexa_devices/const.py +++ b/tests/components/alexa_devices/const.py @@ -18,15 +18,13 @@ TEST_DEVICE_1 = AmazonDevice( online=True, serial_number=TEST_DEVICE_1_SN, software_version="echo_test_software_version", - do_not_disturb=False, - response_style=None, - bluetooth_state=True, entity_id="11111111-2222-3333-4444-555555555555", - appliance_id="G1234567890123456789012345678A", + endpoint_id="G1234567890123456789012345678A", sensors={ + "dnd": AmazonDeviceSensor(name="dnd", value=False, error=False, scale=None), "temperature": AmazonDeviceSensor( - name="temperature", value="22.5", scale="CELSIUS" - ) + name="temperature", value="22.5", error=False, scale="CELSIUS" + ), }, ) @@ -42,14 +40,11 @@ TEST_DEVICE_2 = AmazonDevice( online=True, serial_number=TEST_DEVICE_2_SN, software_version="echo_test_2_software_version", - do_not_disturb=False, - response_style=None, - bluetooth_state=True, entity_id="11111111-2222-3333-4444-555555555555", - appliance_id="G1234567890123456789012345678A", + endpoint_id="G1234567890123456789012345678A", sensors={ "temperature": AmazonDeviceSensor( - name="temperature", value="22.5", scale="CELSIUS" + name="temperature", value="22.5", error=False, scale="CELSIUS" ) }, ) diff --git a/tests/components/alexa_devices/snapshots/test_binary_sensor.ambr b/tests/components/alexa_devices/snapshots/test_binary_sensor.ambr index 16f9eeaedae8..c6b9a2afa08a 100644 --- a/tests/components/alexa_devices/snapshots/test_binary_sensor.ambr +++ b/tests/components/alexa_devices/snapshots/test_binary_sensor.ambr @@ -1,52 +1,4 @@ # serializer version: 1 -# name: test_all_entities[binary_sensor.echo_test_bluetooth-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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.echo_test_bluetooth', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Bluetooth', - 'platform': 'alexa_devices', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'bluetooth', - 'unique_id': 'echo_test_serial_number-bluetooth', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[binary_sensor.echo_test_bluetooth-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Echo Test Bluetooth', - }), - 'context': , - 'entity_id': 'binary_sensor.echo_test_bluetooth', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- # name: test_all_entities[binary_sensor.echo_test_connectivity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr index 9ae5832ce334..2450d9e7d7bb 100644 --- a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr +++ b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr @@ -2,7 +2,6 @@ # name: test_device_diagnostics dict({ 'account name': 'Echo Test', - 'bluetooth state': True, 'capabilities': list([ 'AUDIO_PLAYER', 'MICROPHONE', @@ -12,9 +11,17 @@ ]), 'device family': 'mine', 'device type': 'echo', - 'do not disturb': False, 'online': True, - 'response style': None, + 'sensors': dict({ + 'dnd': dict({ + '__type': "", + 'repr': "AmazonDeviceSensor(name='dnd', value=False, error=False, scale=None)", + }), + 'temperature': dict({ + '__type': "", + 'repr': "AmazonDeviceSensor(name='temperature', value='22.5', error=False, scale='CELSIUS')", + }), + }), 'serial number': 'echo_test_serial_number', 'software version': 'echo_test_software_version', }) @@ -25,7 +32,6 @@ 'devices': list([ dict({ 'account name': 'Echo Test', - 'bluetooth state': True, 'capabilities': list([ 'AUDIO_PLAYER', 'MICROPHONE', @@ -35,9 +41,17 @@ ]), 'device family': 'mine', 'device type': 'echo', - 'do not disturb': False, 'online': True, - 'response style': None, + 'sensors': dict({ + 'dnd': dict({ + '__type': "", + 'repr': "AmazonDeviceSensor(name='dnd', value=False, error=False, scale=None)", + }), + 'temperature': dict({ + '__type': "", + 'repr': "AmazonDeviceSensor(name='temperature', value='22.5', error=False, scale='CELSIUS')", + }), + }), 'serial number': 'echo_test_serial_number', 'software version': 'echo_test_software_version', }), diff --git a/tests/components/alexa_devices/snapshots/test_services.ambr b/tests/components/alexa_devices/snapshots/test_services.ambr index 12eab4a683bf..dc15796c32c6 100644 --- a/tests/components/alexa_devices/snapshots/test_services.ambr +++ b/tests/components/alexa_devices/snapshots/test_services.ambr @@ -4,8 +4,6 @@ tuple( dict({ 'account_name': 'Echo Test', - 'appliance_id': 'G1234567890123456789012345678A', - 'bluetooth_state': True, 'capabilities': list([ 'AUDIO_PLAYER', 'MICROPHONE', @@ -16,12 +14,18 @@ 'device_family': 'mine', 'device_owner_customer_id': 'amazon_ower_id', 'device_type': 'echo', - 'do_not_disturb': False, + 'endpoint_id': 'G1234567890123456789012345678A', 'entity_id': '11111111-2222-3333-4444-555555555555', 'online': True, - 'response_style': None, 'sensors': dict({ + 'dnd': dict({ + 'error': False, + 'name': 'dnd', + 'scale': None, + 'value': False, + }), 'temperature': dict({ + 'error': False, 'name': 'temperature', 'scale': 'CELSIUS', 'value': '22.5', @@ -41,8 +45,6 @@ tuple( dict({ 'account_name': 'Echo Test', - 'appliance_id': 'G1234567890123456789012345678A', - 'bluetooth_state': True, 'capabilities': list([ 'AUDIO_PLAYER', 'MICROPHONE', @@ -53,12 +55,18 @@ 'device_family': 'mine', 'device_owner_customer_id': 'amazon_ower_id', 'device_type': 'echo', - 'do_not_disturb': False, + 'endpoint_id': 'G1234567890123456789012345678A', 'entity_id': '11111111-2222-3333-4444-555555555555', 'online': True, - 'response_style': None, 'sensors': dict({ + 'dnd': dict({ + 'error': False, + 'name': 'dnd', + 'scale': None, + 'value': False, + }), 'temperature': dict({ + 'error': False, 'name': 'temperature', 'scale': 'CELSIUS', 'value': '22.5', diff --git a/tests/components/alexa_devices/snapshots/test_switch.ambr b/tests/components/alexa_devices/snapshots/test_switch.ambr index c622cc67ea75..3ce484cf95b9 100644 --- a/tests/components/alexa_devices/snapshots/test_switch.ambr +++ b/tests/components/alexa_devices/snapshots/test_switch.ambr @@ -30,7 +30,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'do_not_disturb', - 'unique_id': 'echo_test_serial_number-do_not_disturb', + 'unique_id': 'echo_test_serial_number-dnd', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/alexa_devices/test_sensor.py b/tests/components/alexa_devices/test_sensor.py index 560a7e10b90d..3bb1b3f0a0d8 100644 --- a/tests/components/alexa_devices/test_sensor.py +++ b/tests/components/alexa_devices/test_sensor.py @@ -134,10 +134,38 @@ async def test_unit_of_measurement( mock_amazon_devices_client.get_devices_data.return_value[ TEST_DEVICE_1_SN - ].sensors = {sensor: AmazonDeviceSensor(name=sensor, value=api_value, scale=scale)} + ].sensors = { + sensor: AmazonDeviceSensor( + name=sensor, value=api_value, error=False, scale=scale + ) + } await setup_integration(hass, mock_config_entry) assert (state := hass.states.get(entity_id)) assert state.state == state_value assert state.attributes["unit_of_measurement"] == unit + + +async def test_sensor_unavailable( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor is unavailable.""" + + entity_id = "sensor.echo_test_illuminance" + + mock_amazon_devices_client.get_devices_data.return_value[ + TEST_DEVICE_1_SN + ].sensors = { + "illuminance": AmazonDeviceSensor( + name="illuminance", value="800", error=True, scale=None + ) + } + + await setup_integration(hass, mock_config_entry) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/alexa_devices/test_switch.py b/tests/components/alexa_devices/test_switch.py index c5039d68da25..6bbc1f68d021 100644 --- a/tests/components/alexa_devices/test_switch.py +++ b/tests/components/alexa_devices/test_switch.py @@ -1,7 +1,9 @@ """Tests for the Alexa Devices switch platform.""" +from copy import deepcopy from unittest.mock import AsyncMock, patch +from aioamazondevices.api import AmazonDeviceSensor from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -23,10 +25,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import setup_integration -from .conftest import TEST_DEVICE_1_SN +from .conftest import TEST_DEVICE_1, TEST_DEVICE_1_SN from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +ENTITY_ID = "switch.echo_test_do_not_disturb" + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_all_entities( @@ -52,48 +56,59 @@ async def test_switch_dnd( """Test switching DND.""" await setup_integration(hass, mock_config_entry) - entity_id = "switch.echo_test_do_not_disturb" - - assert (state := hass.states.get(entity_id)) + assert (state := hass.states.get(ENTITY_ID)) assert state.state == STATE_OFF await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, + {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True, ) assert mock_amazon_devices_client.set_do_not_disturb.call_count == 1 - mock_amazon_devices_client.get_devices_data.return_value[ - TEST_DEVICE_1_SN - ].do_not_disturb = True + device_data = deepcopy(TEST_DEVICE_1) + device_data.sensors = { + "dnd": AmazonDeviceSensor(name="dnd", value=True, error=False, scale=None), + "temperature": AmazonDeviceSensor( + name="temperature", value="22.5", error=False, scale="CELSIUS" + ), + } + mock_amazon_devices_client.get_devices_data.return_value = { + TEST_DEVICE_1_SN: device_data + } freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert (state := hass.states.get(entity_id)) + assert (state := hass.states.get(ENTITY_ID)) assert state.state == STATE_ON await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: entity_id}, + {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True, ) - mock_amazon_devices_client.get_devices_data.return_value[ - TEST_DEVICE_1_SN - ].do_not_disturb = False + device_data.sensors = { + "dnd": AmazonDeviceSensor(name="dnd", value=False, error=False, scale=None), + "temperature": AmazonDeviceSensor( + name="temperature", value="22.5", error=False, scale="CELSIUS" + ), + } + mock_amazon_devices_client.get_devices_data.return_value = { + TEST_DEVICE_1_SN: device_data + } freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() assert mock_amazon_devices_client.set_do_not_disturb.call_count == 2 - assert (state := hass.states.get(entity_id)) + assert (state := hass.states.get(ENTITY_ID)) assert state.state == STATE_OFF @@ -104,16 +119,13 @@ async def test_offline_device( mock_config_entry: MockConfigEntry, ) -> None: """Test offline device handling.""" - - entity_id = "switch.echo_test_do_not_disturb" - mock_amazon_devices_client.get_devices_data.return_value[ TEST_DEVICE_1_SN ].online = False await setup_integration(hass, mock_config_entry) - assert (state := hass.states.get(entity_id)) + assert (state := hass.states.get(ENTITY_ID)) assert state.state == STATE_UNAVAILABLE mock_amazon_devices_client.get_devices_data.return_value[ @@ -124,5 +136,5 @@ async def test_offline_device( async_fire_time_changed(hass) await hass.async_block_till_done() - assert (state := hass.states.get(entity_id)) + assert (state := hass.states.get(ENTITY_ID)) assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/alexa_devices/test_utils.py b/tests/components/alexa_devices/test_utils.py index 1cf190bd2976..020971d8f76f 100644 --- a/tests/components/alexa_devices/test_utils.py +++ b/tests/components/alexa_devices/test_utils.py @@ -10,8 +10,10 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SERVICE_TUR from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_integration +from .const import TEST_DEVICE_1_SN from tests.common import MockConfigEntry @@ -54,3 +56,41 @@ async def test_alexa_api_call_exceptions( assert exc_info.value.translation_domain == DOMAIN assert exc_info.value.translation_key == key assert exc_info.value.translation_placeholders == {"error": error} + + +async def test_alexa_unique_id_migration( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test unique_id migration.""" + + mock_config_entry.add_to_hass(hass) + + device = 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="Amazon", + model="Echo Dot", + entry_type=dr.DeviceEntryType.SERVICE, + ) + + entity = entity_registry.async_get_or_create( + SWITCH_DOMAIN, + DOMAIN, + unique_id=f"{TEST_DEVICE_1_SN}-do_not_disturb", + device_id=device.id, + config_entry=mock_config_entry, + has_entity_name=True, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + migrated_entity = entity_registry.async_get(entity.entity_id) + assert migrated_entity is not None + assert migrated_entity.config_entry_id == mock_config_entry.entry_id + assert migrated_entity.unique_id == f"{TEST_DEVICE_1_SN}-dnd" From 3c0b13975a0fc1f84813aa20216d72b01f170dc2 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 25 Sep 2025 19:05:12 +0200 Subject: [PATCH 166/189] Update frontend to 20250925.1 (#152985) --- 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 bf7c9642c131..618711c5354a 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==20250925.0"] + "requirements": ["home-assistant-frontend==20250925.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f9d165d5b3b4..725e5269a91c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==5.6.4 hass-nabucasa==1.1.2 hassil==3.2.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250925.0 +home-assistant-frontend==20250925.1 home-assistant-intents==2025.9.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c2f4528d1aad..4fd13040322e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1186,7 +1186,7 @@ hole==0.9.0 holidays==0.81 # homeassistant.components.frontend -home-assistant-frontend==20250925.0 +home-assistant-frontend==20250925.1 # homeassistant.components.conversation home-assistant-intents==2025.9.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 48d8d367b3ae..b9cdfb90e5bf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1035,7 +1035,7 @@ hole==0.9.0 holidays==0.81 # homeassistant.components.frontend -home-assistant-frontend==20250925.0 +home-assistant-frontend==20250925.1 # homeassistant.components.conversation home-assistant-intents==2025.9.24 From 35faaa6cae429b5992a2fb11ad839a8d47f5b651 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 25 Sep 2025 19:19:27 +0200 Subject: [PATCH 167/189] Add missing square brackets to references in `fully_kiosk` actions (#152987) --- homeassistant/components/fully_kiosk/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index 785124575ba8..11c91c1f637a 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -162,8 +162,8 @@ "description": "Sets a configuration parameter on Fully Kiosk Browser.", "fields": { "device_id": { - "name": "%key:component::fully_kiosk::services::load_url::fields::device_id::name%", - "description": "%key:component::fully_kiosk::services::load_url::fields::device_id::description%" + "name": "[%key:component::fully_kiosk::services::load_url::fields::device_id::name%]", + "description": "[%key:component::fully_kiosk::services::load_url::fields::device_id::description%]" }, "key": { "name": "Key", @@ -184,8 +184,8 @@ "description": "Package name of the application to start." }, "device_id": { - "name": "%key:component::fully_kiosk::services::load_url::fields::device_id::name%", - "description": "%key:component::fully_kiosk::services::load_url::fields::device_id::description%" + "name": "[%key:component::fully_kiosk::services::load_url::fields::device_id::name%]", + "description": "[%key:component::fully_kiosk::services::load_url::fields::device_id::description%]" } } } From c4389a1679b6af100f0012aa4a9f058cb6d1021b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Sep 2025 12:21:17 -0500 Subject: [PATCH 168/189] Bump aioesphomeapi to 41.10.0 (#152975) Co-authored-by: Michael Hansen --- 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 674ced0bf9c6..2918f79ed2d2 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==41.9.4", + "aioesphomeapi==41.10.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.3.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 4fd13040322e..bf00576c5d2a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.9.4 +aioesphomeapi==41.10.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b9cdfb90e5bf..9549fca52ff1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.9.4 +aioesphomeapi==41.10.0 # homeassistant.components.flo aioflo==2021.11.0 From 52de5ff5ffb11df825522f2fab07ef7dff9f8bf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 25 Sep 2025 18:23:40 +0100 Subject: [PATCH 169/189] Remove deprecated zone and event condition keys (#152986) --- homeassistant/components/zone/condition.py | 5 +---- homeassistant/helpers/config_validation.py | 3 --- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py index caa75b4e0be1..90c6761efc56 100644 --- a/homeassistant/components/zone/condition.py +++ b/homeassistant/components/zone/condition.py @@ -30,12 +30,9 @@ from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import in_zone -_OPTIONS_SCHEMA_DICT = { +_OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = { vol.Required(CONF_ENTITY_ID): cv.entity_ids, vol.Required("zone"): cv.entity_ids, - # To support use_trigger_value in automation - # Deprecated 2016/04/25 - vol.Optional("event"): vol.Any("enter", "leave"), } _CONDITION_SCHEMA = vol.Schema({CONF_OPTIONS: _OPTIONS_SCHEMA_DICT}) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 4e289a1313b4..7110ad267af0 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1545,9 +1545,6 @@ STATE_CONDITION_BASE_SCHEMA = { ), vol.Optional(CONF_ATTRIBUTE): str, vol.Optional(CONF_FOR): positive_time_period_template, - # To support use_trigger_value in automation - # Deprecated 2016/04/25 - vol.Optional("from"): str, } STATE_CONDITION_STATE_SCHEMA = vol.Schema( From 5b70910d77bfc5614f3372328db83a147eba192f Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Thu, 25 Sep 2025 13:34:29 -0500 Subject: [PATCH 170/189] Bump aiorussound to 4.8.2 (#152988) --- homeassistant/components/russound_rio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index efaf8f195adc..b1b35385495d 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.8.1"], + "requirements": ["aiorussound==4.8.2"], "zeroconf": ["_rio._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index bf00576c5d2a..acf7caea4f47 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -375,7 +375,7 @@ aioridwell==2025.09.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.8.1 +aiorussound==4.8.2 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9549fca52ff1..1663089b4f45 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -357,7 +357,7 @@ aioridwell==2025.09.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.8.1 +aiorussound==4.8.2 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 From 7450b3fd1a25e561f7e27af35ae540ebfe54e56f Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 25 Sep 2025 21:39:44 +0200 Subject: [PATCH 171/189] Improve tests for Alexa Devices (#152995) --- tests/components/alexa_devices/test_binary_sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/alexa_devices/test_binary_sensor.py b/tests/components/alexa_devices/test_binary_sensor.py index bcb89664da46..6b55a701b459 100644 --- a/tests/components/alexa_devices/test_binary_sensor.py +++ b/tests/components/alexa_devices/test_binary_sensor.py @@ -123,6 +123,8 @@ async def test_dynamic_device( assert (state := hass.states.get(entity_id_1)) assert state.state == STATE_ON + assert not hass.states.get(entity_id_2) + mock_amazon_devices_client.get_devices_data.return_value = { TEST_DEVICE_1_SN: TEST_DEVICE_1, TEST_DEVICE_2_SN: TEST_DEVICE_2, From 6d0470064f53e6b82bab1fb70f48cd131e9f6377 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Thu, 25 Sep 2025 15:54:06 -0400 Subject: [PATCH 172/189] Rename service to action in ESPHome (#152997) --- homeassistant/components/esphome/manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index c3db4c3e9e8e..239dfe5662ac 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -1073,7 +1073,7 @@ def _async_register_service( service_name, { "description": ( - f"Calls the service {service.name} of the node {device_info.name}" + f"Performs the action {service.name} of the node {device_info.name}" ), "fields": fields, }, From ec62b0cdfbcc36df8db0b167295c4ee97d58b4b6 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 26 Sep 2025 01:34:09 +0200 Subject: [PATCH 173/189] Code optimization for Uptime Robot (#152993) --- .../components/uptimerobot/binary_sensor.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index 52e490222fc7..0a0f973c6e00 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -28,11 +28,12 @@ async def async_setup_entry( known_devices: set[int] = set() def _check_device() -> None: - current_devices = {monitor.id for monitor in coordinator.data} - new_devices = current_devices - known_devices - if new_devices: - known_devices.update(new_devices) - async_add_entities( + entities: list[UptimeRobotBinarySensor] = [] + for monitor in coordinator.data: + if monitor.id in known_devices: + continue + known_devices.add(monitor.id) + entities.append( UptimeRobotBinarySensor( coordinator, BinarySensorEntityDescription( @@ -41,9 +42,9 @@ async def async_setup_entry( ), monitor=monitor, ) - for monitor in coordinator.data - if monitor.id in new_devices ) + if entities: + async_add_entities(entities) _check_device() entry.async_on_unload(coordinator.async_add_listener(_check_device)) From 487b9ff03e7412f5a8c88d7f474602f1dca7d8b9 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 25 Sep 2025 23:44:25 -0400 Subject: [PATCH 174/189] Bump ZHA to 0.0.73 (#153007) --- 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 86763f9c2127..307b287d8f54 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.72"], + "requirements": ["zha==0.0.73"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index acf7caea4f47..0e21cd8e2bc0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3235,7 +3235,7 @@ zeroconf==0.147.2 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.72 +zha==0.0.73 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1663089b4f45..f8af022083d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2682,7 +2682,7 @@ zeroconf==0.147.2 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.72 +zha==0.0.73 # homeassistant.components.zwave_js zwave-js-server-python==0.67.1 From c1b9c0e1b679e64ccd8989dd0d9646a45befc06e Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 26 Sep 2025 01:17:01 -0400 Subject: [PATCH 175/189] Ignore discovery for existing ZHA entries (#152984) --- homeassistant/components/zha/config_flow.py | 49 +++++++--- tests/components/zha/test_config_flow.py | 99 +++++++++++++++++---- 2 files changed, 115 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 5f90a3fc7d6e..dab157977dfc 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -23,6 +23,7 @@ from homeassistant.components.homeassistant_hardware import silabs_multiprotocol from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware from homeassistant.config_entries import ( SOURCE_IGNORE, + SOURCE_ZEROCONF, ConfigEntry, ConfigEntryBaseFlow, ConfigEntryState, @@ -183,27 +184,17 @@ class BaseZhaFlow(ConfigEntryBaseFlow): self._hass = hass self._radio_mgr.hass = hass - async def _get_config_entry_data(self) -> dict: + def _get_config_entry_data(self) -> dict[str, Any]: """Extract ZHA config entry data from the radio manager.""" assert self._radio_mgr.radio_type is not None assert self._radio_mgr.device_path is not None assert self._radio_mgr.device_settings is not None - try: - device_path = await self.hass.async_add_executor_job( - usb.get_serial_by_id, self._radio_mgr.device_path - ) - except OSError as error: - raise AbortFlow( - reason="cannot_resolve_path", - description_placeholders={"path": self._radio_mgr.device_path}, - ) from error - return { CONF_DEVICE: DEVICE_SCHEMA( { **self._radio_mgr.device_settings, - CONF_DEVICE_PATH: device_path, + CONF_DEVICE_PATH: self._radio_mgr.device_path, } ), CONF_RADIO_TYPE: self._radio_mgr.radio_type.name, @@ -703,6 +694,36 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): DOMAIN, include_ignore=False ) + if self._radio_mgr.device_path is not None: + # Ensure the radio manager device path is unique and will match ZHA's + try: + self._radio_mgr.device_path = await self.hass.async_add_executor_job( + usb.get_serial_by_id, self._radio_mgr.device_path + ) + except OSError as error: + raise AbortFlow( + reason="cannot_resolve_path", + description_placeholders={"path": self._radio_mgr.device_path}, + ) from error + + # mDNS discovery can advertise the same adapter on multiple IPs or via a + # hostname, which should be considered a duplicate + current_device_paths = {self._radio_mgr.device_path} + + if self.source == SOURCE_ZEROCONF: + discovery_info = self.init_data + current_device_paths |= { + f"socket://{ip}:{discovery_info.port}" + for ip in discovery_info.ip_addresses + } + + for entry in zha_config_entries: + path = entry.data.get(CONF_DEVICE, {}).get(CONF_DEVICE_PATH) + + # Abort discovery if the device path is already configured + if path is not None and path in current_device_paths: + return self.async_abort(reason="single_instance_allowed") + # Without confirmation, discovery can automatically progress into parts of the # config flow logic that interacts with hardware. if user_input is not None or ( @@ -873,7 +894,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): zha_config_entries = self.hass.config_entries.async_entries( DOMAIN, include_ignore=False ) - data = await self._get_config_entry_data() + data = self._get_config_entry_data() if len(zha_config_entries) == 1: return self.async_update_reload_and_abort( @@ -976,7 +997,7 @@ class ZhaOptionsFlowHandler(BaseZhaFlow, OptionsFlow): # Avoid creating both `.options` and `.data` by directly writing `data` here self.hass.config_entries.async_update_entry( entry=self.config_entry, - data=await self._get_config_entry_data(), + data=self._get_config_entry_data(), options=self.config_entry.options, ) diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index ff4c7443fa13..0ddea074c799 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -857,6 +857,40 @@ async def test_discovery_via_usb_zha_ignored_updates(hass: HomeAssistant) -> Non } +async def test_discovery_via_usb_same_device_already_setup(hass: HomeAssistant) -> None: + """Test discovery aborting if ZHA is already setup.""" + MockConfigEntry( + domain=DOMAIN, + data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/serial/by-id/usb-device123"}}, + ).add_to_hass(hass) + + # Discovery info with the same device but different path format + discovery_info = UsbServiceInfo( + device="/dev/ttyUSB0", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) + + with patch( + "homeassistant.components.zha.config_flow.usb.get_serial_by_id", + return_value="/dev/serial/by-id/usb-device123", + ) as mock_get_serial_by_id: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + # Verify get_serial_by_id was called to normalize the path + assert mock_get_serial_by_id.mock_calls == [call("/dev/ttyUSB0")] + + # Should abort since it's the same device + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) async def test_legacy_zeroconf_discovery_already_setup(hass: HomeAssistant) -> None: @@ -890,6 +924,39 @@ async def test_legacy_zeroconf_discovery_already_setup(hass: HomeAssistant) -> N assert confirm_result["step_id"] == "choose_migration_strategy" +async def test_zeroconf_discovery_via_socket_already_setup_with_ip_match( + hass: HomeAssistant, +) -> None: + """Test zeroconf discovery aborting when ZHA is already setup with socket and one IP matches.""" + MockConfigEntry( + domain=DOMAIN, + data={CONF_DEVICE: {CONF_DEVICE_PATH: "socket://192.168.1.101:6638"}}, + ).add_to_hass(hass) + + service_info = ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ + ip_address("192.168.1.100"), + ip_address("192.168.1.101"), # Matches config entry + ], + hostname="tube-zigbee-gw.local.", + name="mock_name", + port=6638, + properties={"name": "tube_123456"}, + type="mock_type", + ) + + # Discovery should abort due to single instance check + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=service_info + ) + await hass.async_block_till_done() + + # Should abort since one of the advertised IPs matches existing socket path + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + @patch( "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", mock_detect_radio_type(radio_type=RadioType.deconz), @@ -2289,34 +2356,28 @@ async def test_config_flow_serial_resolution_oserror( ) -> None: """Test that OSError during serial port resolution is handled.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": "manual_pick_radio_type"}, - data={CONF_RADIO_TYPE: RadioType.ezsp.description}, + discovery_info = UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB33"}, - ) - - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "choose_setup_strategy" - with ( patch( - "homeassistant.components.usb.get_serial_by_id", + "homeassistant.components.zha.config_flow.usb.get_serial_by_id", side_effect=OSError("Test error"), ), ): - setup_result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED}, + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USB}, data=discovery_info ) - assert setup_result["type"] is FlowResultType.ABORT - assert setup_result["reason"] == "cannot_resolve_path" - assert setup_result["description_placeholders"] == {"path": "/dev/ttyUSB33"} + assert result_init["type"] is FlowResultType.ABORT + assert result_init["reason"] == "cannot_resolve_path" + assert result_init["description_placeholders"] == {"path": "/dev/ttyZIGBEE"} @patch("homeassistant.components.zha.radio_manager._allow_overwrite_ezsp_ieee") From c523c45d179f54a16a5484738d4e419af4ac9788 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 26 Sep 2025 01:39:00 -0400 Subject: [PATCH 176/189] Allow ZHA discovery if discovery `unique_id` conflicts with config entry (#153009) Co-authored-by: Martin Hjelmare Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/zha/config_flow.py | 9 ++------- tests/components/zha/test_config_flow.py | 13 ++++--------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index dab157977dfc..95c4593089b6 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -653,13 +653,8 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): """Set the flow's unique ID and update the device path in an ignored flow.""" current_entry = await self.async_set_unique_id(unique_id) - if not current_entry: - return - - if current_entry.source != SOURCE_IGNORE: - self._abort_if_unique_id_configured() - else: - # Only update the current entry if it is an ignored discovery + # Only update the current entry if it is an ignored discovery + if current_entry and current_entry.source == SOURCE_IGNORE: self._abort_if_unique_id_configured( updates={ CONF_DEVICE: { diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 0ddea074c799..cb0ad5dc6d7e 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -708,8 +708,8 @@ async def test_multiple_zha_entries_aborts(hass: HomeAssistant, mock_app) -> Non @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) -async def test_discovery_via_usb_path_does_not_change(hass: HomeAssistant) -> None: - """Test usb flow already set up and the path does not change.""" +async def test_discovery_via_usb_duplicate_unique_id(hass: HomeAssistant) -> None: + """Test USB discovery when a config entry with a duplicate unique_id already exists.""" entry = MockConfigEntry( domain=DOMAIN, @@ -737,13 +737,8 @@ async def test_discovery_via_usb_path_does_not_change(hass: HomeAssistant) -> No ) await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - assert entry.data[CONF_DEVICE] == { - CONF_DEVICE_PATH: "/dev/ttyUSB1", - CONF_BAUDRATE: 115200, - CONF_FLOW_CONTROL: None, - } + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) From d11c171c75a3828244b15e1ee4634d80bacc9445 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 26 Sep 2025 07:49:38 +0200 Subject: [PATCH 177/189] Bump aiohomeconnect to version 0.20.0 (#153003) --- homeassistant/components/home_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 1a2761aa65fc..b9fc230e749f 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -22,6 +22,6 @@ "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], "quality_scale": "platinum", - "requirements": ["aiohomeconnect==0.19.0"], + "requirements": ["aiohomeconnect==0.20.0"], "zeroconf": ["_homeconnect._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 0e21cd8e2bc0..ce79798446ad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -268,7 +268,7 @@ aioharmony==0.5.3 aiohasupervisor==0.3.3b0 # homeassistant.components.home_connect -aiohomeconnect==0.19.0 +aiohomeconnect==0.20.0 # homeassistant.components.homekit_controller aiohomekit==3.2.18 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f8af022083d7..29b17cfc420e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -253,7 +253,7 @@ aioharmony==0.5.3 aiohasupervisor==0.3.3b0 # homeassistant.components.home_connect -aiohomeconnect==0.19.0 +aiohomeconnect==0.20.0 # homeassistant.components.homekit_controller aiohomekit==3.2.18 From 9bf361a1b8124b6d370225226d65d0537cce3d47 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 26 Sep 2025 08:59:03 +0200 Subject: [PATCH 178/189] Fix PIN failure if starting with 0 for Comelit SimpleHome (#152983) --- .../components/comelit/config_flow.py | 12 +++-- tests/components/comelit/const.py | 7 +-- tests/components/comelit/test_config_flow.py | 46 ++++++++++++++++++- 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py index 5b09b582c66a..0f47d88fad19 100644 --- a/homeassistant/components/comelit/config_flow.py +++ b/homeassistant/components/comelit/config_flow.py @@ -25,23 +25,27 @@ from .const import _LOGGER, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN from .utils import async_client_session DEFAULT_HOST = "192.168.1.252" -DEFAULT_PIN = 111111 +DEFAULT_PIN = "111111" +pin_regex = r"^[0-9]{4,10}$" + USER_SCHEMA = vol.Schema( { vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int, + vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.matches_regex(pin_regex), vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST), } ) -STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.positive_int}) +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_PIN): cv.matches_regex(pin_regex)} +) STEP_RECONFIGURE = vol.Schema( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.port, - vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int, + vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.matches_regex(pin_regex), } ) diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index 3a253e4b5964..f275c192dd4f 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -20,13 +20,14 @@ from aiocomelit.const import ( BRIDGE_HOST = "fake_bridge_host" BRIDGE_PORT = 80 -BRIDGE_PIN = 1234 +BRIDGE_PIN = "1234" VEDO_HOST = "fake_vedo_host" VEDO_PORT = 8080 -VEDO_PIN = 5678 +VEDO_PIN = "5678" -FAKE_PIN = 0000 +FAKE_PIN = "0000" +BAD_PIN = "abcd" LIGHT0 = ComelitSerialBridgeObject( index=0, diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py index 1751a837026e..90622bbe457c 100644 --- a/tests/components/comelit/test_config_flow.py +++ b/tests/components/comelit/test_config_flow.py @@ -10,9 +10,10 @@ from homeassistant.components.comelit.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.data_entry_flow import FlowResultType, InvalidData from .const import ( + BAD_PIN, BRIDGE_HOST, BRIDGE_PIN, BRIDGE_PORT, @@ -310,3 +311,46 @@ async def test_reconfigure_fails( CONF_PIN: BRIDGE_PIN, CONF_TYPE: BRIDGE, } + + +async def test_pin_format_serial_bridge( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test PIN is valid format.""" + + 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 pytest.raises(InvalidData): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: BRIDGE_HOST, + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BAD_PIN, + }, + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: BRIDGE_HOST, + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BRIDGE_PIN, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: BRIDGE_HOST, + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BRIDGE_PIN, + CONF_TYPE: BRIDGE, + } + assert not result["result"].unique_id + await hass.async_block_till_done() From 89b327ed7bc4efb4d4cbc17d5fa7250600c391ed Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 26 Sep 2025 09:02:14 +0200 Subject: [PATCH 179/189] Remove device filter from target selector in bang_olufsen services (#152957) --- homeassistant/components/bang_olufsen/services.yaml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/homeassistant/components/bang_olufsen/services.yaml b/homeassistant/components/bang_olufsen/services.yaml index 7c3a2d659bd3..1a7b1028af98 100644 --- a/homeassistant/components/bang_olufsen/services.yaml +++ b/homeassistant/components/bang_olufsen/services.yaml @@ -3,16 +3,12 @@ beolink_allstandby: entity: integration: bang_olufsen domain: media_player - device: - integration: bang_olufsen beolink_expand: target: entity: integration: bang_olufsen domain: media_player - device: - integration: bang_olufsen fields: all_discovered: required: false @@ -37,8 +33,6 @@ beolink_join: entity: integration: bang_olufsen domain: media_player - device: - integration: bang_olufsen fields: jid_options: collapsed: false @@ -71,16 +65,12 @@ beolink_leave: entity: integration: bang_olufsen domain: media_player - device: - integration: bang_olufsen beolink_unexpand: target: entity: integration: bang_olufsen domain: media_player - device: - integration: bang_olufsen fields: jid_options: collapsed: false From b17cc71dfbb5765b2118e574b2d66698e580dc11 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Fri, 26 Sep 2025 11:04:02 +0200 Subject: [PATCH 180/189] Bump to home-assistant/wheels@2025.09.1 (#153025) --- .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 984d1e91c8a2..b6a4d0832f7d 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -160,7 +160,7 @@ jobs: # home-assistant/wheels doesn't support sha pinning - name: Build wheels - uses: home-assistant/wheels@2025.09.0 + uses: home-assistant/wheels@2025.09.1 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -221,7 +221,7 @@ jobs: # home-assistant/wheels doesn't support sha pinning - name: Build wheels - uses: home-assistant/wheels@2025.09.0 + uses: home-assistant/wheels@2025.09.1 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 From ec0380fd3b7ccd9bbc66b093e194ced761e1d218 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Fri, 26 Sep 2025 11:22:14 +0200 Subject: [PATCH 181/189] Snapshot testing for Plugwise Sensor platform (#153021) --- .../plugwise/snapshots/test_sensor.ambr | 8062 +++++++++++++++++ tests/components/plugwise/test_sensor.py | 161 +- 2 files changed, 8125 insertions(+), 98 deletions(-) create mode 100644 tests/components/plugwise/snapshots/test_sensor.ambr diff --git a/tests/components/plugwise/snapshots/test_sensor.ambr b/tests/components/plugwise/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..962493c98cec --- /dev/null +++ b/tests/components/plugwise/snapshots/test_sensor.ambr @@ -0,0 +1,8062 @@ +# serializer version: 1 +# name: test_adam_sensor_snapshot[platforms0][sensor.adam_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.adam_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'outdoor_temperature', + 'unique_id': 'fe799307f1624099878210aa0b9f1475-outdoor_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.adam_outdoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Adam Outdoor temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.adam_outdoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.81', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.badkamer_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.badkamer_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08963fec7c53423ca5680aa4cb502c63-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.badkamer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Badkamer Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.badkamer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.9', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_cv_thermostatic_radiator_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.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-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_cv_thermostatic_radiator_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Bios Cv Thermostatic Radiator Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.bios_cv_thermostatic_radiator_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '62', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_cv_thermostatic_radiator_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_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.bios_cv_thermostatic_radiator_setpoint', + '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': 'Setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'setpoint', + 'unique_id': 'a2c3583e0a6349358998b760cea82d2a-setpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_cv_thermostatic_radiator_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Bios Cv Thermostatic Radiator Setpoint', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bios_cv_thermostatic_radiator_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_cv_thermostatic_radiator_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bios_cv_thermostatic_radiator_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a2c3583e0a6349358998b760cea82d2a-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_cv_thermostatic_radiator_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Bios Cv Thermostatic Radiator Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bios_cv_thermostatic_radiator_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17.2', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_cv_thermostatic_radiator_temperature_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': , + 'entity_id': 'sensor.bios_cv_thermostatic_radiator_temperature_difference', + '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 difference', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_difference', + 'unique_id': 'a2c3583e0a6349358998b760cea82d2a-temperature_difference', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_cv_thermostatic_radiator_temperature_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Bios Cv Thermostatic Radiator Temperature difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bios_cv_thermostatic_radiator_temperature_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-0.2', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_cv_thermostatic_radiator_valve_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_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.bios_cv_thermostatic_radiator_valve_position', + 'has_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 position', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'valve_position', + 'unique_id': 'a2c3583e0a6349358998b760cea82d2a-valve_position', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_cv_thermostatic_radiator_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bios Cv Thermostatic Radiator Valve position', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.bios_cv_thermostatic_radiator_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_electricity_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bios_electricity_consumed', + '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': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': '12493538af164a409c6a1c79e38afe1c-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Bios Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bios_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_electricity_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bios_electricity_produced', + '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': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': '12493538af164a409c6a1c79e38afe1c-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Bios Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bios_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bios_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12493538af164a409c6a1c79e38afe1c-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Bios Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bios_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.5', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_kraan_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.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-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_kraan_garage_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'CV Kraan Garage Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.cv_kraan_garage_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '68', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_kraan_garage_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_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.cv_kraan_garage_setpoint', + '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': 'Setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'setpoint', + 'unique_id': 'e7693eb9582644e5b865dba8d4447cf1-setpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_kraan_garage_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'CV Kraan Garage Setpoint', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.cv_kraan_garage_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.5', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_kraan_garage_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.cv_kraan_garage_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e7693eb9582644e5b865dba8d4447cf1-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_kraan_garage_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'CV Kraan Garage Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.cv_kraan_garage_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.6', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_kraan_garage_temperature_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': , + 'entity_id': 'sensor.cv_kraan_garage_temperature_difference', + '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 difference', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_difference', + 'unique_id': 'e7693eb9582644e5b865dba8d4447cf1-temperature_difference', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_kraan_garage_temperature_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'CV Kraan Garage Temperature difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.cv_kraan_garage_temperature_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_kraan_garage_valve_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_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.cv_kraan_garage_valve_position', + 'has_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 position', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'valve_position', + 'unique_id': 'e7693eb9582644e5b865dba8d4447cf1-valve_position', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_kraan_garage_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CV Kraan Garage Valve position', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.cv_kraan_garage_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_pomp_electricity_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.cv_pomp_electricity_consumed', + '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': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': '78d1126fc4c743db81b61c20e88342a7-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_pomp_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'CV Pomp Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.cv_pomp_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35.6', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_pomp_electricity_consumed_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.cv_pomp_electricity_consumed_interval', + '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': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': '78d1126fc4c743db81b61c20e88342a7-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_pomp_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'CV Pomp Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.cv_pomp_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.37', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_pomp_electricity_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.cv_pomp_electricity_produced', + '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': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': '78d1126fc4c743db81b61c20e88342a7-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_pomp_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'CV Pomp Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.cv_pomp_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_pomp_electricity_produced_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.cv_pomp_electricity_produced_interval', + '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': 'Electricity produced interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_interval', + 'unique_id': '78d1126fc4c743db81b61c20e88342a7-electricity_produced_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_pomp_electricity_produced_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'CV Pomp Electricity produced interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.cv_pomp_electricity_produced_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.fibaro_hc2_electricity_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fibaro_hc2_electricity_consumed', + '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': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': 'a28f588dc4a049a483fd03a30361ad3a-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.fibaro_hc2_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Fibaro HC2 Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fibaro_hc2_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.5', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.fibaro_hc2_electricity_consumed_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fibaro_hc2_electricity_consumed_interval', + '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': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': 'a28f588dc4a049a483fd03a30361ad3a-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.fibaro_hc2_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Fibaro HC2 Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fibaro_hc2_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.8', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.fibaro_hc2_electricity_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fibaro_hc2_electricity_produced', + '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': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': 'a28f588dc4a049a483fd03a30361ad3a-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.fibaro_hc2_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Fibaro HC2 Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fibaro_hc2_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.fibaro_hc2_electricity_produced_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fibaro_hc2_electricity_produced_interval', + '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': 'Electricity produced interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_interval', + 'unique_id': 'a28f588dc4a049a483fd03a30361ad3a-electricity_produced_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.fibaro_hc2_electricity_produced_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Fibaro HC2 Electricity produced interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fibaro_hc2_electricity_produced_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.floor_kraan_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_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.floor_kraan_setpoint', + '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': 'Setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'setpoint', + 'unique_id': 'b310b72a0e354bfab43089919b9a88bf-setpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.floor_kraan_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Floor kraan Setpoint', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.floor_kraan_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.5', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.floor_kraan_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.floor_kraan_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'b310b72a0e354bfab43089919b9a88bf-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.floor_kraan_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Floor kraan Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.floor_kraan_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.floor_kraan_temperature_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': , + 'entity_id': 'sensor.floor_kraan_temperature_difference', + '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 difference', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_difference', + 'unique_id': 'b310b72a0e354bfab43089919b9a88bf-temperature_difference', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.floor_kraan_temperature_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Floor kraan Temperature difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.floor_kraan_temperature_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.5', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.floor_kraan_valve_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_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.floor_kraan_valve_position', + 'has_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 position', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'valve_position', + 'unique_id': 'b310b72a0e354bfab43089919b9a88bf-valve_position', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.floor_kraan_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Floor kraan Valve position', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.floor_kraan_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.garage_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.garage_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '446ac08dd04d4eff8ac57489757b7314-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.garage_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Garage Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.garage_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.6', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.jessie_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.jessie_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '82fa13f017d240daa0d0ea1775420f24-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.jessie_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Jessie Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.jessie_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17.2', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nas_electricity_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nas_electricity_consumed', + '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': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': 'cd0ddb54ef694e11ac18ed1cbce5dbbd-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nas_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'NAS Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nas_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.5', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nas_electricity_consumed_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nas_electricity_consumed_interval', + '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': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': 'cd0ddb54ef694e11ac18ed1cbce5dbbd-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nas_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'NAS Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nas_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.5', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nas_electricity_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nas_electricity_produced', + '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': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': 'cd0ddb54ef694e11ac18ed1cbce5dbbd-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nas_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'NAS Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nas_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nas_electricity_produced_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nas_electricity_produced_interval', + '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': 'Electricity produced interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_interval', + 'unique_id': 'cd0ddb54ef694e11ac18ed1cbce5dbbd-electricity_produced_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nas_electricity_produced_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'NAS Electricity produced interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nas_electricity_produced_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nvr_electricity_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nvr_electricity_consumed', + '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': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': '02cf28bfec924855854c544690a609ef-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nvr_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'NVR Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nvr_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nvr_electricity_consumed_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nvr_electricity_consumed_interval', + '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': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': '02cf28bfec924855854c544690a609ef-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nvr_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'NVR Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nvr_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.15', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nvr_electricity_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nvr_electricity_produced', + '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': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': '02cf28bfec924855854c544690a609ef-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nvr_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'NVR Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nvr_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nvr_electricity_produced_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nvr_electricity_produced_interval', + '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': 'Electricity produced interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_interval', + 'unique_id': '02cf28bfec924855854c544690a609ef-electricity_produced_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nvr_electricity_produced_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'NVR Electricity produced interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nvr_electricity_produced_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.onoff_intended_boiler_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.onoff_intended_boiler_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': 'Intended boiler temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'intended_boiler_temperature', + 'unique_id': '90986d591dcd426cae3ec3e8111ff730-intended_boiler_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.onoff_intended_boiler_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'OnOff Intended boiler temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.onoff_intended_boiler_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.onoff_modulation_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': , + 'entity_id': 'sensor.onoff_modulation_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': 'Modulation level', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'modulation_level', + 'unique_id': '90986d591dcd426cae3ec3e8111ff730-modulation_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.onoff_modulation_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'OnOff Modulation level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.onoff_modulation_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.onoff_water_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.onoff_water_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': 'Water temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_temperature', + 'unique_id': '90986d591dcd426cae3ec3e8111ff730-water_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.onoff_water_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'OnOff Water temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.onoff_water_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.playstation_smart_plug_electricity_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.playstation_smart_plug_electricity_consumed', + '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': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': '21f2b542c49845e6bb416884c55778d6-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.playstation_smart_plug_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Playstation Smart Plug Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.playstation_smart_plug_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '84.1', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.playstation_smart_plug_electricity_consumed_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.playstation_smart_plug_electricity_consumed_interval', + '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': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': '21f2b542c49845e6bb416884c55778d6-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.playstation_smart_plug_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Playstation Smart Plug Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.playstation_smart_plug_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.6', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.playstation_smart_plug_electricity_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.playstation_smart_plug_electricity_produced', + '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': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': '21f2b542c49845e6bb416884c55778d6-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.playstation_smart_plug_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Playstation Smart Plug Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.playstation_smart_plug_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.playstation_smart_plug_electricity_produced_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.playstation_smart_plug_electricity_produced_interval', + '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': 'Electricity produced interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_interval', + 'unique_id': '21f2b542c49845e6bb416884c55778d6-electricity_produced_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.playstation_smart_plug_electricity_produced_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Playstation Smart Plug Electricity produced interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.playstation_smart_plug_electricity_produced_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_1_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': '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-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Thermostatic Radiator Badkamer 1 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '51', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_1_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_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.thermostatic_radiator_badkamer_1_setpoint', + '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': 'Setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'setpoint', + 'unique_id': '680423ff840043738f42cc7f1ff97a36-setpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_1_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostatic Radiator Badkamer 1 Setpoint', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_1_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_1_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '680423ff840043738f42cc7f1ff97a36-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostatic Radiator Badkamer 1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.1', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_1_temperature_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': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_1_temperature_difference', + '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 difference', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_difference', + 'unique_id': '680423ff840043738f42cc7f1ff97a36-temperature_difference', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_1_temperature_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostatic Radiator Badkamer 1 Temperature difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_1_temperature_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-0.4', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_1_valve_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_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.thermostatic_radiator_badkamer_1_valve_position', + 'has_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 position', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'valve_position', + 'unique_id': '680423ff840043738f42cc7f1ff97a36-valve_position', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_1_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Thermostatic Radiator Badkamer 1 Valve position', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_1_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_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.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-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_2_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Thermostatic Radiator Badkamer 2 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_2_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '92', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_2_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_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.thermostatic_radiator_badkamer_2_setpoint', + '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': 'Setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'setpoint', + 'unique_id': 'f1fee6043d3642a9b0a65297455f008e-setpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_2_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostatic Radiator Badkamer 2 Setpoint', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_2_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_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': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f1fee6043d3642a9b0a65297455f008e-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_2_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostatic Radiator Badkamer 2 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_2_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.9', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_jessie_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.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-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_jessie_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Thermostatic Radiator Jessie Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_jessie_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '62', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_jessie_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_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.thermostatic_radiator_jessie_setpoint', + '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': 'Setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'setpoint', + 'unique_id': 'd3da73bde12a47d5a6b8f9dad971f2ec-setpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_jessie_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostatic Radiator Jessie Setpoint', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_jessie_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_jessie_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.thermostatic_radiator_jessie_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd3da73bde12a47d5a6b8f9dad971f2ec-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_jessie_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostatic Radiator Jessie Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_jessie_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17.1', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_jessie_temperature_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': , + 'entity_id': 'sensor.thermostatic_radiator_jessie_temperature_difference', + '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 difference', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_difference', + 'unique_id': 'd3da73bde12a47d5a6b8f9dad971f2ec-temperature_difference', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_jessie_temperature_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostatic Radiator Jessie Temperature difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_jessie_temperature_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_jessie_valve_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_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.thermostatic_radiator_jessie_valve_position', + 'has_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 position', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'valve_position', + 'unique_id': 'd3da73bde12a47d5a6b8f9dad971f2ec-valve_position', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_jessie_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Thermostatic Radiator Jessie Valve position', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_jessie_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.usg_smart_plug_electricity_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.usg_smart_plug_electricity_consumed', + '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': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': '4a810418d5394b3f82727340b91ba740-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.usg_smart_plug_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'USG Smart Plug Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.usg_smart_plug_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.5', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.usg_smart_plug_electricity_consumed_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.usg_smart_plug_electricity_consumed_interval', + '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': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': '4a810418d5394b3f82727340b91ba740-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.usg_smart_plug_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'USG Smart Plug Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.usg_smart_plug_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.usg_smart_plug_electricity_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.usg_smart_plug_electricity_produced', + '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': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': '4a810418d5394b3f82727340b91ba740-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.usg_smart_plug_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'USG Smart Plug Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.usg_smart_plug_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.usg_smart_plug_electricity_produced_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.usg_smart_plug_electricity_produced_interval', + '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': 'Electricity produced interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_interval', + 'unique_id': '4a810418d5394b3f82727340b91ba740-electricity_produced_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.usg_smart_plug_electricity_produced_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'USG Smart Plug Electricity produced interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.usg_smart_plug_electricity_produced_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.woonkamer_electricity_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.woonkamer_electricity_consumed', + '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': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': 'c50f167537524366a5af7aa3942feb1e-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.woonkamer_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Woonkamer Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.woonkamer_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35.6', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.woonkamer_electricity_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.woonkamer_electricity_produced', + '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': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': 'c50f167537524366a5af7aa3942feb1e-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.woonkamer_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Woonkamer Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.woonkamer_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.woonkamer_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.woonkamer_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'c50f167537524366a5af7aa3942feb1e-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.woonkamer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Woonkamer Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.woonkamer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.9', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.ziggo_modem_electricity_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ziggo_modem_electricity_consumed', + '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': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': '675416a629f343c495449970e2ca37b5-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.ziggo_modem_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Ziggo Modem Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ziggo_modem_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.2', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.ziggo_modem_electricity_consumed_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ziggo_modem_electricity_consumed_interval', + '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': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': '675416a629f343c495449970e2ca37b5-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.ziggo_modem_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Ziggo Modem Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ziggo_modem_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.97', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.ziggo_modem_electricity_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ziggo_modem_electricity_produced', + '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': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': '675416a629f343c495449970e2ca37b5-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.ziggo_modem_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Ziggo Modem Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ziggo_modem_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.ziggo_modem_electricity_produced_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ziggo_modem_electricity_produced_interval', + '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': 'Electricity produced interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_interval', + 'unique_id': '675416a629f343c495449970e2ca37b5-electricity_produced_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.ziggo_modem_electricity_produced_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Ziggo Modem Electricity produced interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ziggo_modem_electricity_produced_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_bios_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.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-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_bios_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Zone Lisa Bios Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.zone_lisa_bios_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '67', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_bios_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_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.zone_lisa_bios_setpoint', + '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': 'Setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'setpoint', + 'unique_id': 'df4a4a8169904cdb9c03d61a21f42140-setpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_bios_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Zone Lisa Bios Setpoint', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zone_lisa_bios_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_bios_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.zone_lisa_bios_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'df4a4a8169904cdb9c03d61a21f42140-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_bios_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Zone Lisa Bios Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zone_lisa_bios_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.5', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_wk_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.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-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_wk_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Zone Lisa WK Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.zone_lisa_wk_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_wk_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_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.zone_lisa_wk_setpoint', + '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': 'Setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'setpoint', + 'unique_id': 'b59bcebaf94b499ea7d46e4a66fb62d8-setpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_wk_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Zone Lisa WK Setpoint', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zone_lisa_wk_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.5', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_wk_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.zone_lisa_wk_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'b59bcebaf94b499ea7d46e4a66fb62d8-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_wk_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Zone Lisa WK Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zone_lisa_wk_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.9', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_thermostat_jessie_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.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-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_thermostat_jessie_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Zone Thermostat Jessie Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.zone_thermostat_jessie_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_thermostat_jessie_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_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.zone_thermostat_jessie_setpoint', + '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': 'Setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'setpoint', + 'unique_id': '6a3bf693d05e48e0b460c815a4fdd09d-setpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_thermostat_jessie_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Zone Thermostat Jessie Setpoint', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zone_thermostat_jessie_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_thermostat_jessie_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.zone_thermostat_jessie_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '6a3bf693d05e48e0b460c815a4fdd09d-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_thermostat_jessie_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Zone Thermostat Jessie Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zone_thermostat_jessie_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17.2', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.anna_cooling_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_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.anna_cooling_setpoint', + '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': 'Cooling setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_setpoint', + 'unique_id': '3cb70739631c4d17a86b8b12e8a5161b-setpoint_high', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.anna_cooling_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Anna Cooling setpoint', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.anna_cooling_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.0', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.anna_heating_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_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.anna_heating_setpoint', + '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': 'Heating setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating_setpoint', + 'unique_id': '3cb70739631c4d17a86b8b12e8a5161b-setpoint_low', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.anna_heating_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Anna Heating setpoint', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.anna_heating_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.5', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.anna_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': , + 'entity_id': 'sensor.anna_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3cb70739631c4d17a86b8b12e8a5161b-illuminance', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.anna_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Anna Illuminance', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.anna_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '86.0', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.anna_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.anna_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3cb70739631c4d17a86b8b12e8a5161b-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.anna_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Anna Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.anna_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.3', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_dhw_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.opentherm_dhw_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': 'DHW temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dhw_temperature', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-dhw_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_dhw_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'OpenTherm DHW temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.opentherm_dhw_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '46.3', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_intended_boiler_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.opentherm_intended_boiler_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': 'Intended boiler temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'intended_boiler_temperature', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-intended_boiler_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_intended_boiler_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'OpenTherm Intended boiler temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.opentherm_intended_boiler_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35.0', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_modulation_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': , + 'entity_id': 'sensor.opentherm_modulation_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': 'Modulation level', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'modulation_level', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-modulation_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_modulation_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'OpenTherm Modulation level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.opentherm_modulation_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '52', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_outdoor_air_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.opentherm_outdoor_air_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 air temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'outdoor_air_temperature', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-outdoor_air_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_outdoor_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'OpenTherm Outdoor air temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.opentherm_outdoor_air_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_return_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.opentherm_return_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': 'Return temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'return_temperature', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-return_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_return_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'OpenTherm Return temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.opentherm_return_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.1', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_water_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': , + 'entity_id': 'sensor.opentherm_water_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': 'Water pressure', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_pressure', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-water_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_water_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'OpenTherm Water pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.opentherm_water_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.57', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_water_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.opentherm_water_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': 'Water temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_temperature', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-water_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_water_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'OpenTherm Water temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.opentherm_water_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29.1', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.smile_anna_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.smile_anna_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'outdoor_temperature', + 'unique_id': '015ae9ea3f964e668e490fa39da3870b-outdoor_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.smile_anna_outdoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Smile Anna Outdoor temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smile_anna_outdoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.2', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_off_peak_cumulative-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_consumed_off_peak_cumulative', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed off-peak cumulative', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_off_peak_cumulative', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_consumed_off_peak_cumulative', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_off_peak_cumulative-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity consumed off-peak cumulative', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_off_peak_cumulative', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70537.898', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_off_peak_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_consumed_off_peak_interval', + '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': 'Electricity consumed off-peak interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_off_peak_interval', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_consumed_off_peak_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_off_peak_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity consumed off-peak interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_off_peak_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '314', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_off_peak_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_consumed_off_peak_point', + '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': 'Electricity consumed off-peak point', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_off_peak_point', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_consumed_off_peak_point', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_off_peak_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity consumed off-peak point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_off_peak_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5553', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_peak_cumulative-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_consumed_peak_cumulative', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed peak cumulative', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_peak_cumulative', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_consumed_peak_cumulative', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_peak_cumulative-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity consumed peak cumulative', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_peak_cumulative', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '161328.641', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_peak_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_consumed_peak_interval', + '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': 'Electricity consumed peak interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_peak_interval', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_consumed_peak_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_peak_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity consumed peak interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_peak_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_peak_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_consumed_peak_point', + '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': 'Electricity consumed peak point', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_peak_point', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_consumed_peak_point', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_peak_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity consumed peak point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_peak_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_one_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_phase_one_consumed', + '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': 'Electricity phase one consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_phase_one_consumed', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_phase_one_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_one_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity phase one consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_phase_one_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1763', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_one_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_phase_one_produced', + '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': 'Electricity phase one produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_phase_one_produced', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_phase_one_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_one_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity phase one produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_phase_one_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_three_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_phase_three_consumed', + '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': 'Electricity phase three consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_phase_three_consumed', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_phase_three_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_three_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity phase three consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_phase_three_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2080', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_three_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_phase_three_produced', + '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': 'Electricity phase three produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_phase_three_produced', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_phase_three_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_three_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity phase three produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_phase_three_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_two_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_phase_two_consumed', + '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': 'Electricity phase two consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_phase_two_consumed', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_phase_two_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_two_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity phase two consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_phase_two_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1703', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_two_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_phase_two_produced', + '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': 'Electricity phase two produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_phase_two_produced', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_phase_two_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_two_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity phase two produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_phase_two_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_off_peak_cumulative-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_produced_off_peak_cumulative', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced off-peak cumulative', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_off_peak_cumulative', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_produced_off_peak_cumulative', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_off_peak_cumulative-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity produced off-peak cumulative', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_off_peak_cumulative', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_off_peak_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_produced_off_peak_interval', + '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': 'Electricity produced off-peak interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_off_peak_interval', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_produced_off_peak_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_off_peak_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity produced off-peak interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_off_peak_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_off_peak_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_produced_off_peak_point', + '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': 'Electricity produced off-peak point', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_off_peak_point', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_produced_off_peak_point', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_off_peak_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity produced off-peak point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_off_peak_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_peak_cumulative-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_produced_peak_cumulative', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced peak cumulative', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_peak_cumulative', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_produced_peak_cumulative', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_peak_cumulative-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity produced peak cumulative', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_peak_cumulative', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_peak_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_produced_peak_interval', + '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': 'Electricity produced peak interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_peak_interval', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_produced_peak_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_peak_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity produced peak interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_peak_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_peak_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_produced_peak_point', + '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': 'Electricity produced peak point', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_peak_point', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_produced_peak_point', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_peak_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity produced peak point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_peak_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_gas_consumed_cumulative-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_gas_consumed_cumulative', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gas consumed cumulative', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gas_consumed_cumulative', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-gas_consumed_cumulative', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_gas_consumed_cumulative-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gas', + 'friendly_name': 'P1 Gas consumed cumulative', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_gas_consumed_cumulative', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16811.37', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_gas_consumed_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_gas_consumed_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Gas consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gas_consumed_interval', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-gas_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_gas_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'P1 Gas consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_gas_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.06', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_net_electricity_cumulative-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_net_electricity_cumulative', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Net electricity cumulative', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'net_electricity_cumulative', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-net_electricity_cumulative', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_net_electricity_cumulative-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Net electricity cumulative', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_net_electricity_cumulative', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '231866.539', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_net_electricity_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_net_electricity_point', + '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': 'Net electricity point', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'net_electricity_point', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-net_electricity_point', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_net_electricity_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Net electricity point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_net_electricity_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5553', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_voltage_phase_one-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_voltage_phase_one', + '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 phase one', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_phase_one', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-voltage_phase_one', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_voltage_phase_one-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'P1 Voltage phase one', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_voltage_phase_one', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '233.2', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_voltage_phase_three-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_voltage_phase_three', + '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 phase three', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_phase_three', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-voltage_phase_three', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_voltage_phase_three-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'P1 Voltage phase three', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_voltage_phase_three', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '234.7', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_voltage_phase_two-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_voltage_phase_two', + '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 phase two', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_phase_two', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-voltage_phase_two', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_voltage_phase_two-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'P1 Voltage phase two', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_voltage_phase_two', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '234.4', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_off_peak_cumulative-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_consumed_off_peak_cumulative', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed off-peak cumulative', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_off_peak_cumulative', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_consumed_off_peak_cumulative', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_off_peak_cumulative-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity consumed off-peak cumulative', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_off_peak_cumulative', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17643.423', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_off_peak_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_consumed_off_peak_interval', + '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': 'Electricity consumed off-peak interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_off_peak_interval', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_consumed_off_peak_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_off_peak_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity consumed off-peak interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_off_peak_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_off_peak_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_consumed_off_peak_point', + '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': 'Electricity consumed off-peak point', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_off_peak_point', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_consumed_off_peak_point', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_off_peak_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity consumed off-peak point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_off_peak_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '486', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_peak_cumulative-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_consumed_peak_cumulative', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed peak cumulative', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_peak_cumulative', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_consumed_peak_cumulative', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_peak_cumulative-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity consumed peak cumulative', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_peak_cumulative', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13966.608', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_peak_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_consumed_peak_interval', + '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': 'Electricity consumed peak interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_peak_interval', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_consumed_peak_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_peak_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity consumed peak interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_peak_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_peak_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_consumed_peak_point', + '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': 'Electricity consumed peak point', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_peak_point', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_consumed_peak_point', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_peak_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity consumed peak point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_peak_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_phase_one_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_phase_one_consumed', + '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': 'Electricity phase one consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_phase_one_consumed', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_phase_one_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_phase_one_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity phase one consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_phase_one_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '486', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_phase_one_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_phase_one_produced', + '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': 'Electricity phase one produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_phase_one_produced', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_phase_one_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_phase_one_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity phase one produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_phase_one_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_off_peak_cumulative-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_produced_off_peak_cumulative', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced off-peak cumulative', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_off_peak_cumulative', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_produced_off_peak_cumulative', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_off_peak_cumulative-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity produced off-peak cumulative', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_off_peak_cumulative', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_off_peak_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_produced_off_peak_interval', + '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': 'Electricity produced off-peak interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_off_peak_interval', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_produced_off_peak_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_off_peak_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity produced off-peak interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_off_peak_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_off_peak_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_produced_off_peak_point', + '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': 'Electricity produced off-peak point', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_off_peak_point', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_produced_off_peak_point', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_off_peak_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity produced off-peak point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_off_peak_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_peak_cumulative-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_produced_peak_cumulative', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced peak cumulative', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_peak_cumulative', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_produced_peak_cumulative', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_peak_cumulative-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity produced peak cumulative', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_peak_cumulative', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_peak_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_produced_peak_interval', + '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': 'Electricity produced peak interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_peak_interval', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_produced_peak_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_peak_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity produced peak interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_peak_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_peak_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_electricity_produced_peak_point', + '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': 'Electricity produced peak point', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_peak_point', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_produced_peak_point', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_peak_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity produced peak point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_peak_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_net_electricity_cumulative-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_net_electricity_cumulative', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Net electricity cumulative', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'net_electricity_cumulative', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-net_electricity_cumulative', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_net_electricity_cumulative-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Net electricity cumulative', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_net_electricity_cumulative', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '31610.031', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_net_electricity_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_net_electricity_point', + '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': 'Net electricity point', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'net_electricity_point', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-net_electricity_point', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_net_electricity_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Net electricity point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_net_electricity_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '486', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.boiler_1eb31_electricity_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.boiler_1eb31_electricity_consumed', + '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': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': '5871317346d045bc9f6b987ef25ee638-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.boiler_1eb31_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Boiler (1EB31) Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.boiler_1eb31_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.19', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.boiler_1eb31_electricity_consumed_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.boiler_1eb31_electricity_consumed_interval', + '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': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': '5871317346d045bc9f6b987ef25ee638-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.boiler_1eb31_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Boiler (1EB31) Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.boiler_1eb31_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.boiler_1eb31_electricity_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.boiler_1eb31_electricity_produced', + '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': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': '5871317346d045bc9f6b987ef25ee638-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.boiler_1eb31_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Boiler (1EB31) Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.boiler_1eb31_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.droger_52559_electricity_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_52559_electricity_consumed', + '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': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': 'cfe95cf3de1948c0b8955125bf754614-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.droger_52559_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Droger (52559) Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.droger_52559_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.droger_52559_electricity_consumed_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_52559_electricity_consumed_interval', + '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': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': 'cfe95cf3de1948c0b8955125bf754614-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.droger_52559_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Droger (52559) Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.droger_52559_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.droger_52559_electricity_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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_52559_electricity_produced', + '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': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': 'cfe95cf3de1948c0b8955125bf754614-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.droger_52559_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Droger (52559) Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.droger_52559_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.koelkast_92c4a_electricity_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.koelkast_92c4a_electricity_consumed', + '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': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': 'e1c884e7dede431dadee09506ec4f859-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.koelkast_92c4a_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Koelkast (92C4A) Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.koelkast_92c4a_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.5', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.koelkast_92c4a_electricity_consumed_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.koelkast_92c4a_electricity_consumed_interval', + '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': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': 'e1c884e7dede431dadee09506ec4f859-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.koelkast_92c4a_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Koelkast (92C4A) Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.koelkast_92c4a_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.08', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.koelkast_92c4a_electricity_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.koelkast_92c4a_electricity_produced', + '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': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': 'e1c884e7dede431dadee09506ec4f859-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.koelkast_92c4a_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Koelkast (92C4A) Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.koelkast_92c4a_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.vaatwasser_2a1ab_electricity_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vaatwasser_2a1ab_electricity_consumed', + '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': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': 'aac7b735042c4832ac9ff33aae4f453b-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.vaatwasser_2a1ab_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Vaatwasser (2a1ab) Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vaatwasser_2a1ab_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.vaatwasser_2a1ab_electricity_consumed_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vaatwasser_2a1ab_electricity_consumed_interval', + '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': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': 'aac7b735042c4832ac9ff33aae4f453b-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.vaatwasser_2a1ab_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Vaatwasser (2a1ab) Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vaatwasser_2a1ab_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.71', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.vaatwasser_2a1ab_electricity_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vaatwasser_2a1ab_electricity_produced', + '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': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': 'aac7b735042c4832ac9ff33aae4f453b-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.vaatwasser_2a1ab_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Vaatwasser (2a1ab) Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vaatwasser_2a1ab_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.wasmachine_52ac1_electricity_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.wasmachine_52ac1_electricity_consumed', + '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': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': '059e4d03c7a34d278add5c7a4a781d19-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.wasmachine_52ac1_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Wasmachine (52AC1) Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.wasmachine_52ac1_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.wasmachine_52ac1_electricity_consumed_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.wasmachine_52ac1_electricity_consumed_interval', + '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': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': '059e4d03c7a34d278add5c7a4a781d19-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.wasmachine_52ac1_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Wasmachine (52AC1) Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.wasmachine_52ac1_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.wasmachine_52ac1_electricity_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.wasmachine_52ac1_electricity_produced', + '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': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': '059e4d03c7a34d278add5c7a4a781d19-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.wasmachine_52ac1_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Wasmachine (52AC1) Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.wasmachine_52ac1_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- diff --git a/tests/components/plugwise/test_sensor.py b/tests/components/plugwise/test_sensor.py index c6c6c6cc284e..1538c8e691f3 100644 --- a/tests/components/plugwise/test_sensor.py +++ b/tests/components/plugwise/test_sensor.py @@ -3,49 +3,35 @@ from unittest.mock import MagicMock import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.plugwise.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_component import async_update_entity -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform -async def test_adam_climate_sensor_entities( - hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_sensor_snapshot( + hass: HomeAssistant, + mock_smile_adam: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test creation of climate related sensor entities.""" - state = hass.states.get("sensor.adam_outdoor_temperature") - assert state - assert float(state.state) == 7.81 - - state = hass.states.get("sensor.cv_pomp_electricity_consumed") - assert state - assert float(state.state) == 35.6 - - state = hass.states.get("sensor.onoff_water_temperature") - assert state - assert float(state.state) == 70.0 - - state = hass.states.get("sensor.cv_pomp_electricity_consumed_interval") - assert state - assert float(state.state) == 7.37 - - await async_update_entity(hass, "sensor.zone_lisa_wk_battery") - - state = hass.states.get("sensor.zone_lisa_wk_battery") - assert state - assert int(state.state) == 34 + """Test Adam sensor snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) -async def test_adam_climate_sensor_entity_2( +async def test_adam_climate_sensor_humidity( hass: HomeAssistant, mock_smile_adam_jip: MagicMock, init_integration: MockConfigEntry, ) -> None: - """Test creation of climate related sensor entities.""" + """Test creation of climate related humidity sensor entity.""" state = hass.states.get("sensor.woonkamer_humidity") assert state assert float(state.state) == 56.2 @@ -96,83 +82,51 @@ async def test_unique_id_migration_humidity( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) -async def test_anna_as_smt_climate_sensor_entities( - hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_anna_sensor_snapshot( + hass: HomeAssistant, + mock_smile_anna: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test creation of climate related sensor entities.""" - state = hass.states.get("sensor.opentherm_outdoor_air_temperature") - assert state - assert float(state.state) == 3.0 - - state = hass.states.get("sensor.opentherm_water_temperature") - assert state - assert float(state.state) == 29.1 - - state = hass.states.get("sensor.opentherm_dhw_temperature") - assert state - assert float(state.state) == 46.3 - - state = hass.states.get("sensor.anna_illuminance") - assert state - assert float(state.state) == 86.0 + """Test Anna sensor snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) @pytest.mark.parametrize("chosen_env", ["p1v4_442_single"], indirect=True) @pytest.mark.parametrize( "gateway_id", ["a455b61e52394b2db5081ce025a430f3"], indirect=True ) -async def test_p1_dsmr_sensor_entities( - hass: HomeAssistant, mock_smile_p1: MagicMock, init_integration: MockConfigEntry +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_p1_dsmr_sensor_snapshot( + hass: HomeAssistant, + mock_smile_p1: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test creation of power related sensor entities.""" - state = hass.states.get("sensor.p1_net_electricity_point") - assert state - assert int(state.state) == 486 - - state = hass.states.get("sensor.p1_electricity_consumed_off_peak_cumulative") - assert state - assert float(state.state) == 17643.423 - - state = hass.states.get("sensor.p1_electricity_produced_peak_point") - assert state - assert int(state.state) == 0 - - state = hass.states.get("sensor.p1_electricity_consumed_peak_cumulative") - assert state - assert float(state.state) == 13966.608 - - state = hass.states.get("sensor.p1_gas_consumed_cumulative") - assert not state + """Test P1 1-phase sensor snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) @pytest.mark.parametrize("chosen_env", ["p1v4_442_triple"], indirect=True) @pytest.mark.parametrize( "gateway_id", ["03e65b16e4b247a29ae0d75a78cb492e"], indirect=True ) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_p1_3ph_dsmr_sensor_entities( +async def test_p1_3ph_dsmr_sensor_snapshot( hass: HomeAssistant, - entity_registry: er.EntityRegistry, mock_smile_p1: MagicMock, - init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test creation of power related sensor entities.""" - state = hass.states.get("sensor.p1_electricity_phase_one_consumed") - assert state - assert int(state.state) == 1763 - - state = hass.states.get("sensor.p1_electricity_phase_two_consumed") - assert state - assert int(state.state) == 1703 - - state = hass.states.get("sensor.p1_electricity_phase_three_consumed") - assert state - assert int(state.state) == 2080 - - # Default disabled sensor test - state = hass.states.get("sensor.p1_voltage_phase_one") - assert state - assert float(state.state) == 233.2 + """Test P1 3-phase sensor snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) @pytest.mark.parametrize("chosen_env", ["p1v4_442_triple"], indirect=True) @@ -186,18 +140,29 @@ async def test_p1_3ph_dsmr_sensor_disabled_entities( init_integration: MockConfigEntry, ) -> None: """Test disabled power related sensor entities intent.""" - state = hass.states.get("sensor.p1_voltage_phase_one") + entity_id = "sensor.p1_voltage_phase_one" + state = hass.states.get(entity_id) assert not state + entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) + await hass.async_block_till_done() -async def test_stretch_sensor_entities( - hass: HomeAssistant, mock_stretch: MagicMock, init_integration: MockConfigEntry + await hass.config_entries.async_reload(init_integration.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.p1_voltage_phase_one") + assert state + assert float(state.state) == 233.2 + + +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_stretch_sensor_snapshot( + hass: HomeAssistant, + mock_stretch: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test creation of power related sensor entities.""" - state = hass.states.get("sensor.koelkast_92c4a_electricity_consumed") - assert state - assert float(state.state) == 50.5 - - state = hass.states.get("sensor.droger_52559_electricity_consumed_interval") - assert state - assert float(state.state) == 0.0 + """Test Stretch sensor snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) From 7a4d75bc44accb5d9c2a9b14d56f8c9889149556 Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Fri, 26 Sep 2025 18:11:59 +0800 Subject: [PATCH 182/189] Add garage door opener for switchbot integration (#148460) --- .../components/switchbot/__init__.py | 2 + homeassistant/components/switchbot/const.py | 4 ++ homeassistant/components/switchbot/cover.py | 31 ++++++++- tests/components/switchbot/__init__.py | 44 +++++++++++++ tests/components/switchbot/test_cover.py | 39 +++++++++++ tests/components/switchbot/test_switch.py | 65 ++++++++++++++----- 6 files changed, 169 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index fa2422923bb8..415ba4d48daf 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -100,6 +100,7 @@ PLATFORMS_BY_TYPE = { SupportedModels.RGBICWW_STRIP_LIGHT.value: [Platform.LIGHT, Platform.SENSOR], SupportedModels.PLUG_MINI_EU.value: [Platform.SWITCH, Platform.SENSOR], SupportedModels.RELAY_SWITCH_2PM.value: [Platform.SWITCH, Platform.SENSOR], + SupportedModels.GARAGE_DOOR_OPENER.value: [Platform.COVER, Platform.SENSOR], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, @@ -133,6 +134,7 @@ CLASS_BY_DEVICE = { SupportedModels.RGBICWW_STRIP_LIGHT.value: switchbot.SwitchbotRgbicLight, SupportedModels.PLUG_MINI_EU.value: switchbot.SwitchbotRelaySwitch, SupportedModels.RELAY_SWITCH_2PM.value: switchbot.SwitchbotRelaySwitch2PM, + SupportedModels.GARAGE_DOOR_OPENER.value: switchbot.SwitchbotGarageDoorOpener, } diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 247191d9c840..80f7978f4dc3 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -56,6 +56,7 @@ class SupportedModels(StrEnum): PLUG_MINI_EU = "plug_mini_eu" RELAY_SWITCH_2PM = "relay_switch_2pm" K11_PLUS_VACUUM = "k11+_vacuum" + GARAGE_DOOR_OPENER = "garage_door_opener" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -91,6 +92,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.PLUG_MINI_EU: SupportedModels.PLUG_MINI_EU, SwitchbotModel.RELAY_SWITCH_2PM: SupportedModels.RELAY_SWITCH_2PM, SwitchbotModel.K11_VACUUM: SupportedModels.K11_PLUS_VACUUM, + SwitchbotModel.GARAGE_DOOR_OPENER: SupportedModels.GARAGE_DOOR_OPENER, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -126,6 +128,7 @@ ENCRYPTED_MODELS = { SwitchbotModel.RGBICWW_FLOOR_LAMP, SwitchbotModel.PLUG_MINI_EU, SwitchbotModel.RELAY_SWITCH_2PM, + SwitchbotModel.GARAGE_DOOR_OPENER, } ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[ @@ -146,6 +149,7 @@ ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[ SwitchbotModel.RGBICWW_FLOOR_LAMP: switchbot.SwitchbotRgbicLight, SwitchbotModel.PLUG_MINI_EU: switchbot.SwitchbotRelaySwitch, SwitchbotModel.RELAY_SWITCH_2PM: switchbot.SwitchbotRelaySwitch2PM, + SwitchbotModel.GARAGE_DOOR_OPENER: switchbot.SwitchbotRelaySwitch, } HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = { diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index 9124dc7f8468..09cb13c3aea2 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -35,7 +35,9 @@ async def async_setup_entry( ) -> None: """Set up Switchbot curtain based on a config entry.""" coordinator = entry.runtime_data - if isinstance(coordinator.device, switchbot.SwitchbotBlindTilt): + if isinstance(coordinator.device, switchbot.SwitchbotGarageDoorOpener): + async_add_entities([SwitchbotGarageDoorOpenerEntity(coordinator)]) + elif isinstance(coordinator.device, switchbot.SwitchbotBlindTilt): async_add_entities([SwitchBotBlindTiltEntity(coordinator)]) elif isinstance(coordinator.device, switchbot.SwitchbotRollerShade): async_add_entities([SwitchBotRollerShadeEntity(coordinator)]) @@ -295,3 +297,30 @@ class SwitchBotRollerShadeEntity(SwitchbotEntity, CoverEntity, RestoreEntity): self._attr_is_closed = self.parsed_data["position"] <= 20 self.async_write_ha_state() + + +class SwitchbotGarageDoorOpenerEntity(SwitchbotEntity, CoverEntity): + """Representation of a Switchbot garage door.""" + + _device: switchbot.SwitchbotGarageDoorOpener + _attr_device_class = CoverDeviceClass.GARAGE + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + _attr_translation_key = "garage_door" + _attr_name = None + + @property + def is_closed(self) -> bool | None: + """Return true if cover is closed, else False.""" + return not self._device.door_open() + + @exception_handler + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the garage door.""" + await self._device.open() + self.async_write_ha_state() + + @exception_handler + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the garage door.""" + await self._device.close() + self.async_write_ha_state() diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 497b3b8a07d0..9fc401270fba 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -1127,3 +1127,47 @@ K11_PLUS_VACUUM_SERVICE_INFO = BluetoothServiceInfoBleak( connectable=True, tx_power=-127, ) + + +RELAY_SWITCH_1_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Relay Switch 1", + manufacturer_data={2409: b"$X|\x0866G\x81\x00\x00\x001\x00\x00\x00\x00"}, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b";\x00\x00\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Relay Switch 1", + manufacturer_data={2409: b"$X|\x0866G\x81\x00\x00\x001\x00\x00\x00\x00"}, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"=\x00\x00\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Relay Switch 1"), + time=0, + connectable=True, + tx_power=-127, +) + + +GARAGE_DOOR_OPENER_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Garage Door Opener", + manufacturer_data={2409: b"$X|\x05BN\x0f\x00\x00\x03\x00\x00\x00\x00\x00\x00"}, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b">\x00\x00\x00", + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Garage Door Opener", + manufacturer_data={2409: b"$X|\x05BN\x0f\x00\x00\x03\x00\x00\x00\x00\x00\x00"}, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b">\x00\x00\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Garage Door Opener"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_cover.py b/tests/components/switchbot/test_cover.py index 9430a45d106d..670e855d8f88 100644 --- a/tests/components/switchbot/test_cover.py +++ b/tests/components/switchbot/test_cover.py @@ -30,6 +30,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from . import ( + GARAGE_DOOR_OPENER_SERVICE_INFO, ROLLER_SHADE_SERVICE_INFO, WOBLINDTILT_SERVICE_INFO, WOCURTAIN3_SERVICE_INFO, @@ -648,3 +649,41 @@ async def test_exception_handling_cover_service( {**service_data, ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +@pytest.mark.parametrize( + ("service", "mock_method"), + [ + (SERVICE_OPEN_COVER, "open"), + (SERVICE_CLOSE_COVER, "close"), + ], +) +async def test_garage_door_opener_controlling( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + service: str, + mock_method: str, +) -> None: + """Test Garage Door Opener controlling.""" + inject_bluetooth_service_info(hass, GARAGE_DOOR_OPENER_SERVICE_INFO) + + entry = mock_entry_encrypted_factory(sensor_type="garage_door_opener") + entry.add_to_hass(hass) + entity_id = "cover.test_name" + + mocked_instance = AsyncMock(return_value=True) + with patch.multiple( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotGarageDoorOpener", + update=AsyncMock(), + **{mock_method: mocked_instance}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mocked_instance.assert_awaited_once() diff --git a/tests/components/switchbot/test_switch.py b/tests/components/switchbot/test_switch.py index edab2fdaddcf..3754dbf8170e 100644 --- a/tests/components/switchbot/test_switch.py +++ b/tests/components/switchbot/test_switch.py @@ -19,8 +19,10 @@ from homeassistant.exceptions import HomeAssistantError from . import ( PLUG_MINI_EU_SERVICE_INFO, + RELAY_SWITCH_1_SERVICE_INFO, RELAY_SWITCH_2PM_SERVICE_INFO, WOHAND_SERVICE_INFO, + WORELAY_SWITCH_1PM_SERVICE_INFO, ) from tests.common import MockConfigEntry, mock_restore_cache @@ -114,6 +116,8 @@ async def test_exception_handling_switch( ("sensor_type", "service_info"), [ ("plug_mini_eu", PLUG_MINI_EU_SERVICE_INFO), + ("relay_switch_1", RELAY_SWITCH_1_SERVICE_INFO), + ("relay_switch_1pm", WORELAY_SWITCH_1PM_SERVICE_INFO), ], ) @pytest.mark.parametrize( @@ -207,11 +211,37 @@ async def test_relay_switch_2pm_control( @pytest.mark.parametrize( - ("exception", "error_message"), + ("sensor_type", "service_info", "entity_id", "mock_class"), [ ( - SwitchbotOperationError("Operation failed"), - "An error occurred while performing the action: Operation failed", + "relay_switch_1", + RELAY_SWITCH_1_SERVICE_INFO, + "switch.test_name", + "SwitchbotRelaySwitch", + ), + ( + "relay_switch_1pm", + WORELAY_SWITCH_1PM_SERVICE_INFO, + "switch.test_name", + "SwitchbotRelaySwitch", + ), + ( + "plug_mini_eu", + PLUG_MINI_EU_SERVICE_INFO, + "switch.test_name", + "SwitchbotRelaySwitch", + ), + ( + "relay_switch_2pm", + RELAY_SWITCH_2PM_SERVICE_INFO, + "switch.test_name_channel_1", + "SwitchbotRelaySwitch2PM", + ), + ( + "relay_switch_2pm", + RELAY_SWITCH_2PM_SERVICE_INFO, + "switch.test_name_channel_2", + "SwitchbotRelaySwitch2PM", ), ], ) @@ -223,29 +253,34 @@ async def test_relay_switch_2pm_control( ], ) @pytest.mark.parametrize( - "entry_id", + ("exception", "error_message"), [ - "switch.test_name_channel_1", - "switch.test_name_channel_2", + ( + SwitchbotOperationError("Operation failed"), + "An error occurred while performing the action: Operation failed", + ), ], ) -async def test_relay_switch_2pm_exception( +async def test_relay_switch_control_with_exception( hass: HomeAssistant, mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], - exception: Exception, - error_message: str, + sensor_type: str, + service_info: BluetoothServiceInfoBleak, + entity_id: str, + mock_class: str, service: str, mock_method: str, - entry_id: str, + exception: Exception, + error_message: str, ) -> None: - """Test Relay Switch 2PM exception handling.""" - inject_bluetooth_service_info(hass, RELAY_SWITCH_2PM_SERVICE_INFO) + """Test Relay Switch control with exception.""" + inject_bluetooth_service_info(hass, service_info) - entry = mock_entry_encrypted_factory(sensor_type="relay_switch_2pm") + entry = mock_entry_encrypted_factory(sensor_type=sensor_type) entry.add_to_hass(hass) with patch.multiple( - "homeassistant.components.switchbot.switch.switchbot.SwitchbotRelaySwitch2PM", + f"homeassistant.components.switchbot.switch.switchbot.{mock_class}", update=AsyncMock(return_value=None), **{mock_method: AsyncMock(side_effect=exception)}, ): @@ -256,6 +291,6 @@ async def test_relay_switch_2pm_exception( await hass.services.async_call( SWITCH_DOMAIN, service, - {ATTR_ENTITY_ID: entry_id}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) From cc16af7f2d5318cd64f191c32610050c05746139 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 26 Sep 2025 12:29:02 +0200 Subject: [PATCH 183/189] Code optimization for Uptime Robot (#153031) --- homeassistant/components/uptimerobot/sensor.py | 15 ++++++++------- homeassistant/components/uptimerobot/switch.py | 15 ++++++++------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/uptimerobot/sensor.py b/homeassistant/components/uptimerobot/sensor.py index 7a241d6999be..60866154ac0e 100644 --- a/homeassistant/components/uptimerobot/sensor.py +++ b/homeassistant/components/uptimerobot/sensor.py @@ -37,11 +37,12 @@ async def async_setup_entry( known_devices: set[int] = set() def _check_device() -> None: - current_devices = {monitor.id for monitor in coordinator.data} - new_devices = current_devices - known_devices - if new_devices: - known_devices.update(new_devices) - async_add_entities( + entities: list[UptimeRobotSensor] = [] + for monitor in coordinator.data: + if monitor.id in known_devices: + continue + known_devices.add(monitor.id) + entities.append( UptimeRobotSensor( coordinator, SensorEntityDescription( @@ -59,9 +60,9 @@ async def async_setup_entry( ), monitor=monitor, ) - for monitor in coordinator.data - if monitor.id in new_devices ) + if entities: + async_add_entities(entities) _check_device() entry.async_on_unload(coordinator.async_add_listener(_check_device)) diff --git a/homeassistant/components/uptimerobot/switch.py b/homeassistant/components/uptimerobot/switch.py index 531131034ce0..41a46e9ff5cf 100644 --- a/homeassistant/components/uptimerobot/switch.py +++ b/homeassistant/components/uptimerobot/switch.py @@ -34,11 +34,12 @@ async def async_setup_entry( known_devices: set[int] = set() def _check_device() -> None: - current_devices = {monitor.id for monitor in coordinator.data} - new_devices = current_devices - known_devices - if new_devices: - known_devices.update(new_devices) - async_add_entities( + entities: list[UptimeRobotSwitch] = [] + for monitor in coordinator.data: + if monitor.id in known_devices: + continue + known_devices.add(monitor.id) + entities.append( UptimeRobotSwitch( coordinator, SwitchEntityDescription( @@ -47,9 +48,9 @@ async def async_setup_entry( ), monitor=monitor, ) - for monitor in coordinator.data - if monitor.id in new_devices ) + if entities: + async_add_entities(entities) _check_device() entry.async_on_unload(coordinator.async_add_listener(_check_device)) From d5f7265424b11770b4aed229eef090359f17698f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Sep 2025 12:31:15 +0200 Subject: [PATCH 184/189] Bump github/codeql-action from 3.30.3 to 3.30.4 (#153015) 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 c3a5073d0389..e1f6061ca565 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Initialize CodeQL - uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3 + uses: github/codeql-action/init@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3 + uses: github/codeql-action/analyze@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4 with: category: "/language:python" From 2af36465f67bab71e0feccebdd156d1f32f7073d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 05:31:59 -0500 Subject: [PATCH 185/189] Bump aioesphomeapi to 41.11.0 (#153014) --- 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 2918f79ed2d2..5229dfddee26 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==41.10.0", + "aioesphomeapi==41.11.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.3.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index ce79798446ad..c4d004488337 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.10.0 +aioesphomeapi==41.11.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 29b17cfc420e..00c7efb0dd30 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.10.0 +aioesphomeapi==41.11.0 # homeassistant.components.flo aioflo==2021.11.0 From 447cb26d28bfa9a156c6b863e9b114396f2e9e3d Mon Sep 17 00:00:00 2001 From: RogerSelwyn Date: Fri, 26 Sep 2025 11:35:04 +0100 Subject: [PATCH 186/189] Protect against last_comms being None (#149366) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/geniushub/entity.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/geniushub/entity.py b/homeassistant/components/geniushub/entity.py index 24917ab5e95e..e47bb59c3d39 100644 --- a/homeassistant/components/geniushub/entity.py +++ b/homeassistant/components/geniushub/entity.py @@ -77,10 +77,10 @@ class GeniusDevice(GeniusEntity): async def async_update(self) -> None: """Update an entity's state data.""" - if "_state" in self._device.data: # only via v3 API - self._last_comms = dt_util.utc_from_timestamp( - self._device.data["_state"]["lastComms"] - ) + if (state := self._device.data.get("_state")) and ( + last_comms := state.get("lastComms") + ) is not None: # only via v3 API + self._last_comms = dt_util.utc_from_timestamp(last_comms) class GeniusZone(GeniusEntity): From 9148ae70ce263e681b64fc3987c62cd2733fd081 Mon Sep 17 00:00:00 2001 From: lliwog <43934544+lliwog@users.noreply.github.com> Date: Fri, 26 Sep 2025 12:47:11 +0200 Subject: [PATCH 187/189] Fix EZVIZ devices merging due to empty MAC addr (#152939) (#152981) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/ezviz/entity.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/ezviz/entity.py b/homeassistant/components/ezviz/entity.py index 54614e4899ac..0a76871285b8 100644 --- a/homeassistant/components/ezviz/entity.py +++ b/homeassistant/components/ezviz/entity.py @@ -26,11 +26,14 @@ class EzvizEntity(CoordinatorEntity[EzvizDataUpdateCoordinator], Entity): super().__init__(coordinator) self._serial = serial self._camera_name = self.data["name"] + + connections = set() + if mac_address := self.data["mac_address"]: + connections.add((CONNECTION_NETWORK_MAC, mac_address)) + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, serial)}, - connections={ - (CONNECTION_NETWORK_MAC, self.data["mac_address"]), - }, + connections=connections, manufacturer=MANUFACTURER, model=self.data["device_sub_category"], name=self.data["name"], @@ -62,11 +65,14 @@ class EzvizBaseEntity(Entity): self._serial = serial self.coordinator = coordinator self._camera_name = self.data["name"] + + connections = set() + if mac_address := self.data["mac_address"]: + connections.add((CONNECTION_NETWORK_MAC, mac_address)) + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, serial)}, - connections={ - (CONNECTION_NETWORK_MAC, self.data["mac_address"]), - }, + connections=connections, manufacturer=MANUFACTURER, model=self.data["device_sub_category"], name=self.data["name"], From f8fd8b432a5046271679cda9a6dcec05d992e0ae Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Fri, 26 Sep 2025 13:03:39 +0200 Subject: [PATCH 188/189] Update Home Assistant base image to 2025.09.2 (#153035) --- build.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/build.yaml b/build.yaml index 127d66145ac6..382a7498e43d 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.09.1 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.09.1 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.09.1 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.09.1 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.09.1 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.09.2 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.09.2 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.09.2 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.09.2 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.09.2 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io From fdca16ea92fb66df28015897a9b51bfe487122ad Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 26 Sep 2025 15:18:18 +0200 Subject: [PATCH 189/189] Fix typing in ObjectSelectorConfig (#153043) --- homeassistant/helpers/selector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 6c162dc08fc7..474d5e715589 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1162,7 +1162,7 @@ class ObjectSelectorConfig(BaseSelectorConfig): fields: dict[str, ObjectSelectorField] multiple: bool label_field: str - description_field: bool + description_field: str translation_key: str