From 0d67cc0795a8a9764c76ebb8172c48e3166940eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Thu, 11 Jun 2026 12:00:41 +0200 Subject: [PATCH 01/20] Add reconfigure flow to aqvify (#173355) Co-authored-by: Joost Lekkerkerker Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/aqvify/config_flow.py | 17 +++++++++- homeassistant/components/aqvify/strings.json | 1 + tests/components/aqvify/test_config_flow.py | 33 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/aqvify/config_flow.py b/homeassistant/components/aqvify/config_flow.py index 64273e35190dcb..263caa4f115998 100644 --- a/homeassistant/components/aqvify/config_flow.py +++ b/homeassistant/components/aqvify/config_flow.py @@ -8,7 +8,11 @@ from pyaqvify import AqvifyAPI, AqvifyAuthException 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_API_KEY from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -49,6 +53,11 @@ async def async_step_user( errors["base"] = "unknown" else: await self.async_set_unique_id(account_data.account_id) + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), data_updates=user_input + ) self._abort_if_unique_id_configured() return self.async_create_entry(title="Aqvify", data=user_input) @@ -96,3 +105,9 @@ async def async_step_reauth_confirm( data_schema=STEP_USER_DATA_SCHEMA, errors=errors, ) + + async def async_step_reconfigure( + self, user_input: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """User initiated reconfiguration.""" + return await self.async_step_user() diff --git a/homeassistant/components/aqvify/strings.json b/homeassistant/components/aqvify/strings.json index dace067c0b007a..88c63f706e1115 100644 --- a/homeassistant/components/aqvify/strings.json +++ b/homeassistant/components/aqvify/strings.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unique_id_mismatch": "The entered API key corresponds to a different account." }, "error": { diff --git a/tests/components/aqvify/test_config_flow.py b/tests/components/aqvify/test_config_flow.py index ba2a48d99be627..1131e027e6528c 100644 --- a/tests/components/aqvify/test_config_flow.py +++ b/tests/components/aqvify/test_config_flow.py @@ -196,3 +196,36 @@ async def test_reauth_flow_error( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("return_value", "expected_reason"), + [ + ("test_account_id", "reconfigure_successful"), + ("test_account_different_id", "unique_id_mismatch"), + ], + ids=["same_account", "different_account"], +) +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_aqvify_client: MagicMock, + return_value: str, + expected_reason: str, +) -> None: + """Test reconfiguration.""" + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_aqvify_client.async_get_account_id.return_value = AqvifyAccount( + {"accountId": return_value} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: "fake-api-key"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_reason From 00a48df8cb886190c15f8bf3d245e3dfe85a06f3 Mon Sep 17 00:00:00 2001 From: Tom Matheussen <13683094+Tommatheussen@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:33:42 +0200 Subject: [PATCH 02/20] Fix Satel Integra arm home mode selection (#173431) --- .../components/satel_integra/config_flow.py | 14 ++++++++++++-- homeassistant/components/satel_integra/const.py | 2 +- .../components/satel_integra/strings.json | 9 ++++++++- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/satel_integra/config_flow.py b/homeassistant/components/satel_integra/config_flow.py index fe20a5105605ee..e9e4130608283a 100644 --- a/homeassistant/components/satel_integra/config_flow.py +++ b/homeassistant/components/satel_integra/config_flow.py @@ -63,11 +63,21 @@ } ) +ARM_HOME_MODE_OPTIONS = ["1", "2", "3"] + PARTITION_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_ARM_HOME_MODE, default=DEFAULT_CONF_ARM_HOME_MODE): vol.In( - [1, 2, 3] + vol.Required(CONF_ARM_HOME_MODE, default=DEFAULT_CONF_ARM_HOME_MODE): vol.All( + vol.Coerce(str), + selector.SelectSelector( + selector.SelectSelectorConfig( + options=ARM_HOME_MODE_OPTIONS, + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="arm_home_mode", + ) + ), + vol.Coerce(int), ), } ) diff --git a/homeassistant/components/satel_integra/const.py b/homeassistant/components/satel_integra/const.py index 929b8f27d09307..8c7b7eed175938 100644 --- a/homeassistant/components/satel_integra/const.py +++ b/homeassistant/components/satel_integra/const.py @@ -1,6 +1,6 @@ """Constants for the Satel Integra integration.""" -DEFAULT_CONF_ARM_HOME_MODE = 1 +DEFAULT_CONF_ARM_HOME_MODE = "1" DEFAULT_PORT = 7094 DOMAIN = "satel_integra" diff --git a/homeassistant/components/satel_integra/strings.json b/homeassistant/components/satel_integra/strings.json index 4d40282b536dd5..a859308748838e 100644 --- a/homeassistant/components/satel_integra/strings.json +++ b/homeassistant/components/satel_integra/strings.json @@ -113,7 +113,7 @@ "partition_number": "Partition number" }, "data_description": { - "arm_home_mode": "The mode in which the partition is armed when 'arm home' is used. For more information on what the differences are between them, please refer to Satel Integra manual.", + "arm_home_mode": "The arming mode to use for 'arm home':\nMode 1 fully arms and bypasses zones that have the 'Bypassed if no exit' option enabled.\nMode 2 disarms interior zones; exterior zones trigger silent alarms and other alarm zones trigger loud alarms.\nMode 3 is like mode 2, but delayed zones are instant.", "name": "The name to give to the alarm panel", "partition_number": "Enter partition number to configure" }, @@ -223,6 +223,13 @@ } }, "selector": { + "arm_home_mode": { + "options": { + "1": "1 - Full arming + bypasses", + "2": "2 - No interior zones", + "3": "3 - No interior zones or entry delay" + } + }, "binary_sensor_device_class": { "options": { "battery": "[%key:component::binary_sensor::entity_component::battery::name%]", From ac5e1f178b231509439d63d9d392a2a641874426 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:27:49 +0200 Subject: [PATCH 03/20] Use parse_module helper in pylint import checker (visit_import) (#173088) --- .../pylint_home_assistant/checkers/imports.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pylint/plugins/pylint_home_assistant/checkers/imports.py b/pylint/plugins/pylint_home_assistant/checkers/imports.py index bd41e9e1739a6d..d94d06074ca59e 100644 --- a/pylint/plugins/pylint_home_assistant/checkers/imports.py +++ b/pylint/plugins/pylint_home_assistant/checkers/imports.py @@ -8,6 +8,7 @@ from pylint.lint import PyLinter from pylint_home_assistant.const import Module +from pylint_home_assistant.helpers.module_info import parse_module @dataclass @@ -230,17 +231,16 @@ def visit_import(self, node: nodes.Import) -> None: """Check for improper `import _` invocations.""" if self.current_package is None: return - for module, _alias in node.names: - if module.startswith(f"{self.current_package}."): + for other_module, _alias in node.names: + if other_module.startswith(f"{self.current_package}."): self.add_message("home-assistant-relative-import", node=node) continue if ( - module.startswith("homeassistant.components.") - and len(module.split(".")) > 3 - ): + other_parsed := parse_module(other_module) + ) and other_parsed.module is not None: if ( self.current_package.startswith("tests.components.") - and self.current_package.split(".")[2] == module.split(".")[2] + and self.current_package.split(".")[2] == other_parsed.domain ): # Ignore check if the component being tested matches # the component being imported from From c83323894c9dec6ff0100af65a1d5e7d9df6e0bb Mon Sep 17 00:00:00 2001 From: Manu Date: Thu, 11 Jun 2026 13:47:55 +0200 Subject: [PATCH 04/20] Add reconfiguration flow to SMTP integration (#173376) --- homeassistant/components/smtp/config_flow.py | 31 ++++ homeassistant/components/smtp/strings.json | 26 ++- tests/components/smtp/test_config_flow.py | 173 +++++++++++++++++++ 3 files changed, 229 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smtp/config_flow.py b/homeassistant/components/smtp/config_flow.py index b9da23602d803e..3de349c676604b 100644 --- a/homeassistant/components/smtp/config_flow.py +++ b/homeassistant/components/smtp/config_flow.py @@ -170,6 +170,37 @@ async def async_on_create_entry(self, result: ConfigFlowResult) -> ConfigFlowRes ) return result + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfigure flow.""" + errors: dict[str, str] = {} + + entry = self._get_reconfigure_entry() + + if user_input is not None: + self._async_abort_entries_match( + { + CONF_SERVER: user_input[CONF_SERVER], + CONF_SENDER: user_input[CONF_SENDER], + CONF_USERNAME: user_input.get(CONF_USERNAME), + } + ) + errors = await self.hass.async_add_executor_job(validate_input, user_input) + if not errors: + return self.async_update_and_abort( + entry, + data=user_input, + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, + suggested_values=user_input or entry.data, + ), + errors=errors, + ) + async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult: """Import config from yaml.""" diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index 849e78ce457a46..40242c20ffcede 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -10,6 +11,29 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reconfigure": { + "data": { + "encryption": "[%key:component::smtp::config::step::user::data::encryption%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", + "sender": "[%key:component::smtp::config::step::user::data::sender%]", + "sender_name": "[%key:component::smtp::config::step::user::data::sender_name%]", + "server": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "encryption": "[%key:component::smtp::config::step::user::data_description::encryption%]", + "password": "[%key:component::smtp::config::step::user::data_description::password%]", + "port": "[%key:component::smtp::config::step::user::data_description::port%]", + "sender": "[%key:component::smtp::config::step::user::data_description::sender%]", + "sender_name": "[%key:component::smtp::config::step::user::data_description::sender_name%]", + "server": "[%key:component::smtp::config::step::user::data_description::server%]", + "username": "[%key:component::smtp::config::step::user::data_description::username%]", + "verify_ssl": "[%key:component::smtp::config::step::user::data_description::verify_ssl%]" + }, + "title": "Reconfigure SMTP" + }, "user": { "data": { "encryption": "Connection security", diff --git a/tests/components/smtp/test_config_flow.py b/tests/components/smtp/test_config_flow.py index ca82c0d9051478..8e988ba8d2e5d7 100644 --- a/tests/components/smtp/test_config_flow.py +++ b/tests/components/smtp/test_config_flow.py @@ -252,3 +252,176 @@ async def test_options_flow( assert config_entry.options == { CONF_TIMEOUT: 10, } + + +@pytest.mark.usefixtures("smtp") +async def test_form_reconfigure( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow.""" + + config_entry.add_to_hass(hass) + + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SENDER: "email@example.com", + CONF_SENDER_NAME: "New sender name", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + CONF_VERIFY_SSL: True, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert config_entry.data == { + CONF_SENDER: "email@example.com", + CONF_SENDER_NAME: "New sender name", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + CONF_VERIFY_SSL: True, + } + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("smtp") +async def test_form_reconfigure_already_configured( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow already configured.""" + + MockConfigEntry( + domain=DOMAIN, + title="Home Assistant", + data={ + CONF_SENDER: "already_configured@example.com", + CONF_SENDER_NAME: "Home Assistant", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_VERIFY_SSL: True, + }, + entry_id="987654321", + ).add_to_hass(hass) + + config_entry.add_to_hass(hass) + + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SENDER: "already_configured@example.com", + CONF_SENDER_NAME: "Home Assistant", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_VERIFY_SSL: True, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert len(hass.config_entries.async_entries()) == 2 + + +@pytest.mark.parametrize( + ("exception", "text_error"), + [ + (SMTPAuthenticationError(0, ""), "invalid_auth"), + (ConnectionRefusedError, "cannot_connect"), + (gaierror, "cannot_connect"), + (SSLCertVerificationError, "invalid_cert"), + (ValueError, "unknown"), + ], +) +@pytest.mark.usefixtures("smtp") +async def test_form_reconfigure_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + smtp: MagicMock, + exception: Exception, + text_error: str, +) -> None: + """Test reconfigure flow connection errors.""" + + smtp.login.side_effect = exception + + config_entry.add_to_hass(hass) + + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SENDER: "email@example.com", + CONF_SENDER_NAME: "New sender name", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + CONF_VERIFY_SSL: True, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + smtp.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SENDER: "email@example.com", + CONF_SENDER_NAME: "New sender name", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + CONF_VERIFY_SSL: True, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert config_entry.data == { + CONF_SENDER: "email@example.com", + CONF_SENDER_NAME: "New sender name", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + CONF_VERIFY_SSL: True, + } + assert len(hass.config_entries.async_entries()) == 1 From 1b582f4089a626b4e1f690a29ccae45ebe8c4daa Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:56:53 +0200 Subject: [PATCH 05/20] Use parse_module helper in pylint import checker (visit_importfrom) (#173375) Co-authored-by: Markus Tuominen <3738613+Markus98@users.noreply.github.com> --- .../pylint_home_assistant/checkers/imports.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/pylint/plugins/pylint_home_assistant/checkers/imports.py b/pylint/plugins/pylint_home_assistant/checkers/imports.py index d94d06074ca59e..defcc97f6581ba 100644 --- a/pylint/plugins/pylint_home_assistant/checkers/imports.py +++ b/pylint/plugins/pylint_home_assistant/checkers/imports.py @@ -314,18 +314,18 @@ def _check_for_component_root_import( self, node: nodes.ImportFrom, current_component: str | None, - imported_parts: list[str], - imported_component: str, + other_component: str, + other_module: str | None, ) -> bool: """Check for hass-component-root-import.""" if ( - current_component == imported_component - or imported_component in _IGNORE_ROOT_IMPORT + current_component == other_component + or other_component in _IGNORE_ROOT_IMPORT ): return True # Check for `from homeassistant.components.other.module import something` - if len(imported_parts) > 3: + if other_module is not None: self.add_message("home-assistant-component-root-import", node=node) return False @@ -385,19 +385,16 @@ def visit_importfrom(self, node: nodes.ImportFrom) -> None: ): return - if node.modname.startswith("homeassistant.components."): - imported_parts = node.modname.split(".") - imported_component = imported_parts[2] - + if other_parsed := parse_module(node.modname): # Checks for hass-component-root-import if not self._check_for_component_root_import( - node, current_component, imported_parts, imported_component + node, current_component, other_parsed.domain, other_parsed.module ): return # Checks for hass-import-constant-alias if not self._check_for_constant_alias( - node, current_component, imported_component + node, current_component, other_parsed.domain ): return From a25a55737fecef6c034f406cac3299af4f3ba24a Mon Sep 17 00:00:00 2001 From: Ken Schulz <2052672+kwschulz@users.noreply.github.com> Date: Thu, 11 Jun 2026 08:31:44 -0400 Subject: [PATCH 06/20] Handle read timeouts in google_wifi sensor update (#173511) Co-authored-by: Claude Fable 5 --- .../components/google_wifi/sensor.py | 4 +- tests/components/google_wifi/test_sensor.py | 67 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py index 9da1a9089a2214..0294fd04875d04 100644 --- a/homeassistant/components/google_wifi/sensor.py +++ b/homeassistant/components/google_wifi/sensor.py @@ -155,7 +155,7 @@ def update(self) -> None: class GoogleWifiAPI: """Get the latest data and update the states.""" - def __init__(self, host, conditions): + def __init__(self, host, conditions) -> None: """Initialize the data object.""" uri = "http://" resource = f"{uri}{host}{ENDPOINT}" @@ -182,7 +182,7 @@ def update(self): self.raw_data = response.json() self.data_format() self.available = True - except ValueError, requests.exceptions.ConnectionError: + except ValueError, requests.exceptions.RequestException: _LOGGER.warning("Unable to fetch data from Google Wifi") self.available = False self.raw_data = None diff --git a/tests/components/google_wifi/test_sensor.py b/tests/components/google_wifi/test_sensor.py index cb8cd15ca5dd1d..824e095975fd04 100644 --- a/tests/components/google_wifi/test_sensor.py +++ b/tests/components/google_wifi/test_sensor.py @@ -5,9 +5,12 @@ from typing import Any from unittest.mock import Mock, patch +import pytest +import requests import requests_mock from homeassistant.components.google_wifi import sensor as google_wifi +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -83,6 +86,23 @@ async def test_setup_get( assert_setup_component(6, "sensor") +async def test_setup_when_router_unreachable( + hass: HomeAssistant, requests_mock: requests_mock.Mocker +) -> None: + """Test platform setup completes when the router does not respond.""" + resource = f"http://{google_wifi.DEFAULT_HOST}{google_wifi.ENDPOINT}" + requests_mock.get(resource, exc=requests.exceptions.ReadTimeout) + assert await async_setup_component( + hass, + "sensor", + {"sensor": {"platform": "google_wifi", "monitored_conditions": ["uptime"]}}, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.google_wifi_uptime") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + def setup_api( hass: HomeAssistant | None, data: str | None, requests_mock: requests_mock.Mocker ) -> tuple[google_wifi.GoogleWifiAPI, dict[str, Any]]: @@ -213,6 +233,53 @@ def test_when_api_data_missing( assert sensor.state is None +@pytest.mark.parametrize( + "mock_kwargs", + [ + pytest.param({"exc": requests.exceptions.ReadTimeout}, id="read_timeout"), + pytest.param({"exc": requests.exceptions.ConnectTimeout}, id="connect_timeout"), + pytest.param( + {"exc": requests.exceptions.ConnectionError}, id="connection_error" + ), + pytest.param( + {"text": "not json", "status_code": HTTPStatus.OK}, id="invalid_json" + ), + ], +) +def test_update_when_request_fails( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + mock_kwargs: dict[str, Any], +) -> None: + """Test sensors become unavailable when the update fails.""" + api, sensor_dict = setup_api(hass, MOCK_DATA, requests_mock) + assert api.available is True + requests_mock.get(f"http://localhost{google_wifi.ENDPOINT}", **mock_kwargs) + api.update(no_throttle=True) + assert api.available is False + for value in sensor_dict.values(): + sensor = value["sensor"] + sensor.update() + assert sensor.state is None + + +def test_update_recovers_after_failure( + hass: HomeAssistant, requests_mock: requests_mock.Mocker +) -> None: + """Test the API recovers once the router responds again.""" + api, sensor_dict = setup_api(hass, MOCK_DATA, requests_mock) + resource = f"http://localhost{google_wifi.ENDPOINT}" + requests_mock.get(resource, exc=requests.exceptions.ReadTimeout) + api.update(no_throttle=True) + assert api.available is False + requests_mock.get(resource, text=MOCK_DATA, status_code=HTTPStatus.OK) + api.update(no_throttle=True) + assert api.available is True + sensor = sensor_dict[google_wifi.ATTR_UPTIME]["sensor"] + sensor.update() + assert sensor.state == 1 + + def test_update_when_unavailable( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: From a4eba86a6c6a5c679f666643318d1ab3a3064b15 Mon Sep 17 00:00:00 2001 From: fdebrus <33791533+fdebrus@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:35:16 +0200 Subject: [PATCH 07/20] Add binary_sensor platform to Vistapool (#172234) Co-authored-by: Claude --- .../components/vistapool/__init__.py | 1 + .../components/vistapool/binary_sensor.py | 255 +++ homeassistant/components/vistapool/const.py | 1 + .../components/vistapool/strings.json | 62 + .../fixtures/pool_data_all_modules.json | 131 ++ .../snapshots/test_binary_sensor.ambr | 1825 +++++++++++++++++ .../vistapool/test_binary_sensor.py | 170 ++ 7 files changed, 2445 insertions(+) create mode 100644 homeassistant/components/vistapool/binary_sensor.py create mode 100644 tests/components/vistapool/fixtures/pool_data_all_modules.json create mode 100644 tests/components/vistapool/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/vistapool/test_binary_sensor.py diff --git a/homeassistant/components/vistapool/__init__.py b/homeassistant/components/vistapool/__init__.py index 0f21964ffa6264..d9987c3c4679d1 100644 --- a/homeassistant/components/vistapool/__init__.py +++ b/homeassistant/components/vistapool/__init__.py @@ -17,6 +17,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT, Platform.NUMBER, diff --git a/homeassistant/components/vistapool/binary_sensor.py b/homeassistant/components/vistapool/binary_sensor.py new file mode 100644 index 00000000000000..a7708682e8bfd3 --- /dev/null +++ b/homeassistant/components/vistapool/binary_sensor.py @@ -0,0 +1,255 @@ +"""Vistapool Binary Sensor entities.""" + +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import VistapoolConfigEntry +from .const import ( + PATH_HASCD, + PATH_HASCL, + PATH_HASHIDRO, + PATH_HASIO, + PATH_HASPH, + PATH_HASRX, +) +from .coordinator import VistapoolDataUpdateCoordinator +from .entity import VistapoolEntity + +PARALLEL_UPDATES = 0 + +TANK_MODULE_PATHS = ( + "modules.ph.tank", + "modules.rx.tank", + "modules.cl.tank", + "modules.cd.tank", +) + + +@dataclass(frozen=True, kw_only=True) +class VistapoolBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes a Vistapool binary sensor entity.""" + + value_path: str + exists_path: str | tuple[str, ...] | None = None + + +BINARY_SENSOR_DESCRIPTIONS: tuple[VistapoolBinarySensorEntityDescription, ...] = ( + VistapoolBinarySensorEntityDescription( + key="filtration", + translation_key="filtration", + device_class=BinarySensorDeviceClass.RUNNING, + value_path="filtration.status", + ), + VistapoolBinarySensorEntityDescription( + key="backwash", + translation_key="backwash", + device_class=BinarySensorDeviceClass.RUNNING, + value_path="backwash.status", + ), + VistapoolBinarySensorEntityDescription( + key="heating", + translation_key="heating", + device_class=BinarySensorDeviceClass.RUNNING, + value_path="relays.filtration.heating.status", + ), + VistapoolBinarySensorEntityDescription( + key="hidro_flow", + translation_key="hidro_flow", + device_class=BinarySensorDeviceClass.PROBLEM, + value_path="hidro.fl1", + exists_path=PATH_HASHIDRO, + ), + VistapoolBinarySensorEntityDescription( + key="hidro_cover_reduction", + translation_key="hidro_cover_reduction", + device_class=BinarySensorDeviceClass.RUNNING, + value_path="hidro.cover", + exists_path=PATH_HASHIDRO, + ), + VistapoolBinarySensorEntityDescription( + key="hidro_fl2", + translation_key="hidro_fl2", + device_class=BinarySensorDeviceClass.PROBLEM, + value_path="hidro.fl2", + exists_path=(PATH_HASHIDRO, PATH_HASCL), + ), + VistapoolBinarySensorEntityDescription( + key="chlorine_pump", + translation_key="chlorine_pump", + device_class=BinarySensorDeviceClass.RUNNING, + value_path="modules.cl.pump_status", + exists_path=PATH_HASCL, + ), + VistapoolBinarySensorEntityDescription( + key="redox_pump", + translation_key="redox_pump", + device_class=BinarySensorDeviceClass.RUNNING, + value_path="modules.rx.pump_status", + exists_path=PATH_HASRX, + ), + VistapoolBinarySensorEntityDescription( + key="ph_pump_alarm", + translation_key="ph_pump_alarm", + device_class=BinarySensorDeviceClass.PROBLEM, + value_path="modules.ph.al3", + exists_path=PATH_HASPH, + ), + VistapoolBinarySensorEntityDescription( + key="ph_acid_pump", + translation_key="ph_acid_pump", + device_class=BinarySensorDeviceClass.RUNNING, + value_path="modules.ph.pump_high_on", + exists_path=PATH_HASPH, + ), + VistapoolBinarySensorEntityDescription( + key="ph_base_pump", + translation_key="ph_base_pump", + device_class=BinarySensorDeviceClass.RUNNING, + value_path="modules.ph.pump_low_on", + exists_path=PATH_HASPH, + ), + VistapoolBinarySensorEntityDescription( + key="conductivity_module", + translation_key="conductivity_module", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_path=PATH_HASCD, + ), + VistapoolBinarySensorEntityDescription( + key="chlorine_module", + translation_key="chlorine_module", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_path=PATH_HASCL, + ), + VistapoolBinarySensorEntityDescription( + key="redox_module", + translation_key="redox_module", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_path=PATH_HASRX, + ), + VistapoolBinarySensorEntityDescription( + key="ph_module", + translation_key="ph_module", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_path=PATH_HASPH, + ), + VistapoolBinarySensorEntityDescription( + key="io_module", + translation_key="io_module", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_path=PATH_HASIO, + ), + VistapoolBinarySensorEntityDescription( + key="hidro_module", + translation_key="hidro_module", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_path=PATH_HASHIDRO, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: VistapoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Vistapool binary sensors for every pool on the account.""" + entities: list[BinarySensorEntity] = [] + + for coordinator in entry.runtime_data.coordinators.values(): + for description in BINARY_SENSOR_DESCRIPTIONS: + if description.exists_path is not None: + required = ( + (description.exists_path,) + if isinstance(description.exists_path, str) + else description.exists_path + ) + if not all(coordinator.get_value(path) for path in required): + continue + entities.append(VistapoolBinarySensor(coordinator, description)) + + if coordinator.get_value(PATH_HASHIDRO): + is_electrolysis = coordinator.get_value("hidro.is_electrolysis") + entities.append( + VistapoolBinarySensor( + coordinator, + VistapoolBinarySensorEntityDescription( + key="electrolysis_low" if is_electrolysis else "hydrolysis_low", + translation_key=( + "electrolysis_low" if is_electrolysis else "hydrolysis_low" + ), + device_class=BinarySensorDeviceClass.PROBLEM, + value_path="hidro.low", + ), + ) + ) + + if any( + coordinator.get_value(path) + for path in (PATH_HASCD, PATH_HASCL, PATH_HASPH, PATH_HASRX) + ): + entities.append(VistapoolDosingTankBinarySensor(coordinator)) + + async_add_entities(entities) + + +class VistapoolBinarySensor(VistapoolEntity, BinarySensorEntity): + """Generic Vistapool binary sensor driven by an entity description.""" + + entity_description: VistapoolBinarySensorEntityDescription + + def __init__( + self, + coordinator: VistapoolDataUpdateCoordinator, + description: VistapoolBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = self.build_unique_id(description.key) + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + value = self.coordinator.get_value(self.entity_description.value_path) + if value is None: + return None + return value in (True, "1") + + +class VistapoolDosingTankBinarySensor(VistapoolEntity, BinarySensorEntity): + """Dosing-tank low-level sensor: on if any installed dosing module reports low.""" + + _attr_device_class = BinarySensorDeviceClass.PROBLEM + _attr_translation_key = "dosing_tank" + + def __init__(self, coordinator: VistapoolDataUpdateCoordinator) -> None: + """Initialize the dosing-tank binary sensor.""" + super().__init__(coordinator) + self._attr_unique_id = self.build_unique_id("dosing_tank") + + @property + def is_on(self) -> bool | None: + """Return true if any tank is low, or None if no tank data is available.""" + values: list[Any] = [] + for path in TANK_MODULE_PATHS: + value = self.coordinator.get_value(path) + if value is not None: + values.append(value) + if not values: + return None + return any(value in (True, "1") for value in values) diff --git a/homeassistant/components/vistapool/const.py b/homeassistant/components/vistapool/const.py index 0bba81f1993900..f7e30b95aaa9c1 100644 --- a/homeassistant/components/vistapool/const.py +++ b/homeassistant/components/vistapool/const.py @@ -7,6 +7,7 @@ PATH_PREFIX = "main." PATH_HASCD = f"{PATH_PREFIX}hasCD" PATH_HASCL = f"{PATH_PREFIX}hasCL" +PATH_HASIO = f"{PATH_PREFIX}hasIO" PATH_HASPH = f"{PATH_PREFIX}hasPH" PATH_HASRX = f"{PATH_PREFIX}hasRX" PATH_HASUV = f"{PATH_PREFIX}hasUV" diff --git a/homeassistant/components/vistapool/strings.json b/homeassistant/components/vistapool/strings.json index 3dc5b716b4264f..dfd566b8505705 100644 --- a/homeassistant/components/vistapool/strings.json +++ b/homeassistant/components/vistapool/strings.json @@ -39,6 +39,68 @@ } }, "entity": { + "binary_sensor": { + "backwash": { + "name": "Backwash" + }, + "chlorine_module": { + "name": "Chlorine module" + }, + "chlorine_pump": { + "name": "Chlorine pump" + }, + "conductivity_module": { + "name": "Conductivity module" + }, + "dosing_tank": { + "name": "Dosing tank" + }, + "electrolysis_low": { + "name": "Electrolysis low" + }, + "filtration": { + "name": "Filtration" + }, + "heating": { + "name": "Heating" + }, + "hidro_cover_reduction": { + "name": "Hidro cover reduction" + }, + "hidro_fl2": { + "name": "Hidro FL2" + }, + "hidro_flow": { + "name": "Hidro flow" + }, + "hidro_module": { + "name": "Hidro module" + }, + "hydrolysis_low": { + "name": "Hydrolysis low" + }, + "io_module": { + "name": "IO module" + }, + "ph_acid_pump": { + "name": "pH acid pump" + }, + "ph_base_pump": { + "name": "pH base pump" + }, + "ph_module": { + "name": "pH module" + }, + "ph_pump_alarm": { + "name": "pH pump alarm" + }, + "redox_module": { + "name": "Redox module" + }, + "redox_pump": { + "name": "Redox pump" + } + }, "button": { "led_pulse": { "name": "LED next color" diff --git a/tests/components/vistapool/fixtures/pool_data_all_modules.json b/tests/components/vistapool/fixtures/pool_data_all_modules.json new file mode 100644 index 00000000000000..5ffde6318770f2 --- /dev/null +++ b/tests/components/vistapool/fixtures/pool_data_all_modules.json @@ -0,0 +1,131 @@ +{ + "main": { + "temperature": 25.5, + "version": 825, + "RSSI": -65, + "hasCD": 1, + "hasCL": 1, + "hasPH": 1, + "hasRX": 1, + "hasUV": 1, + "hasHidro": 1, + "hasIO": 1, + "hasLED": 1, + "localTime": 1775995380 + }, + "modules": { + "ph": { + "current": "742", + "tank": 0, + "pump_high_on": 0, + "pump_low_on": 0, + "al3": 0, + "status": { + "low_value": "650", + "high_value": "751" + } + }, + "rx": { + "current": 707, + "tank": 0, + "status": { + "value": 700 + }, + "pump_status": 0 + }, + "cl": { + "current": "120", + "tank": 0, + "pump_status": 0 + }, + "cd": { + "current": "150", + "tank": 0 + }, + "uv": { + "current": "100" + } + }, + "hidro": { + "current": 50, + "level": 100, + "fl1": 0, + "fl2": 0, + "low": 0, + "cover": 0, + "cover_enabled": 0, + "cloration_enabled": 0, + "maxAllowedValue": 220, + "is_electrolysis": true + }, + "filtration": { + "status": 0, + "mode": 1, + "manVel": 2, + "interval1": { + "from": 28800, + "to": 36000 + }, + "interval2": { + "from": 46800, + "to": 50400 + }, + "interval3": { + "from": 68400, + "to": 70200 + }, + "timerVel1": 1, + "timerVel2": 1, + "timerVel3": 0, + "intel": { + "time": "600", + "temp": 24 + } + }, + "light": { + "status": 0 + }, + "relays": { + "relay1": { + "info": { + "onoff": 0, + "status": 0 + } + }, + "relay2": { + "info": { + "onoff": 0, + "status": 0 + } + }, + "relay3": { + "info": { + "onoff": 0, + "status": 0 + } + }, + "relay4": { + "info": { + "onoff": 0, + "status": 0 + } + }, + "filtration": { + "heating": { + "status": 0 + } + } + }, + "backwash": { + "status": 0 + }, + "form": { + "lat": "50.7", + "lng": "4.4", + "city": "Waterloo", + "street": "Rue Test", + "zipcode": "1410", + "country": "BE" + }, + "present": true +} diff --git a/tests/components/vistapool/snapshots/test_binary_sensor.ambr b/tests/components/vistapool/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000000..47ac1308791781 --- /dev/null +++ b/tests/components/vistapool/snapshots/test_binary_sensor.ambr @@ -0,0 +1,1825 @@ +# serializer version: 1 +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_backwash-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_backwash', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Backwash', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Backwash', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'backwash', + 'unique_id': 'ABCDEF1234567890-backwash', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_backwash-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool Backwash', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_backwash', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_chlorine_module-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_chlorine_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Chlorine module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Chlorine module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'chlorine_module', + 'unique_id': 'ABCDEF1234567890-chlorine_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_chlorine_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool Chlorine module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_chlorine_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_chlorine_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_chlorine_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Chlorine pump', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Chlorine pump', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'chlorine_pump', + 'unique_id': 'ABCDEF1234567890-chlorine_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_chlorine_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool Chlorine pump', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_chlorine_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_conductivity_module-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_conductivity_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Conductivity module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Conductivity module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'conductivity_module', + 'unique_id': 'ABCDEF1234567890-conductivity_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_conductivity_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool Conductivity module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_conductivity_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_dosing_tank-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_dosing_tank', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Dosing tank', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dosing tank', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dosing_tank', + 'unique_id': 'ABCDEF1234567890-dosing_tank', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_dosing_tank-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'My Pool Dosing tank', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_dosing_tank', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_electrolysis_low-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_electrolysis_low', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electrolysis low', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electrolysis low', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electrolysis_low', + 'unique_id': 'ABCDEF1234567890-electrolysis_low', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_electrolysis_low-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'My Pool Electrolysis low', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_electrolysis_low', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_filtration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_filtration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filtration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filtration', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filtration', + 'unique_id': 'ABCDEF1234567890-filtration', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_filtration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool Filtration', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_filtration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_heating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_heating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Heating', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating', + 'unique_id': 'ABCDEF1234567890-heating', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_heating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool Heating', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_heating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_hidro_cover_reduction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_hidro_cover_reduction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hidro cover reduction', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hidro cover reduction', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_cover_reduction', + 'unique_id': 'ABCDEF1234567890-hidro_cover_reduction', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_hidro_cover_reduction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool Hidro cover reduction', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_hidro_cover_reduction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_hidro_fl2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_hidro_fl2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hidro FL2', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hidro FL2', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_fl2', + 'unique_id': 'ABCDEF1234567890-hidro_fl2', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_hidro_fl2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'My Pool Hidro FL2', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_hidro_fl2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_hidro_flow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_hidro_flow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hidro flow', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hidro flow', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_flow', + 'unique_id': 'ABCDEF1234567890-hidro_flow', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_hidro_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'My Pool Hidro flow', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_hidro_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_hidro_module-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_hidro_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hidro module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hidro module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_module', + 'unique_id': 'ABCDEF1234567890-hidro_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_hidro_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool Hidro module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_hidro_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_io_module-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_io_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'IO module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'IO module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'io_module', + 'unique_id': 'ABCDEF1234567890-io_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_io_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool IO module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_io_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_ph_acid_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_ph_acid_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH acid pump', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH acid pump', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_acid_pump', + 'unique_id': 'ABCDEF1234567890-ph_acid_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_ph_acid_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool pH acid pump', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_ph_acid_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_ph_base_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_ph_base_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH base pump', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH base pump', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_base_pump', + 'unique_id': 'ABCDEF1234567890-ph_base_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_ph_base_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool pH base pump', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_ph_base_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_ph_module-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_ph_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'pH module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_module', + 'unique_id': 'ABCDEF1234567890-ph_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_ph_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool pH module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_ph_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_ph_pump_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_ph_pump_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH pump alarm', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH pump alarm', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_pump_alarm', + 'unique_id': 'ABCDEF1234567890-ph_pump_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_ph_pump_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'My Pool pH pump alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_ph_pump_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_redox_module-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_redox_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Redox module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Redox module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'redox_module', + 'unique_id': 'ABCDEF1234567890-redox_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_redox_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool Redox module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_redox_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_redox_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_redox_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Redox pump', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Redox pump', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'redox_pump', + 'unique_id': 'ABCDEF1234567890-redox_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_redox_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool Redox pump', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_redox_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_backwash-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_backwash', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Backwash', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Backwash', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'backwash', + 'unique_id': 'ABCDEF1234567890-backwash', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_backwash-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool Backwash', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_backwash', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_chlorine_module-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_chlorine_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Chlorine module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Chlorine module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'chlorine_module', + 'unique_id': 'ABCDEF1234567890-chlorine_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_chlorine_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool Chlorine module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_chlorine_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_conductivity_module-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_conductivity_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Conductivity module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Conductivity module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'conductivity_module', + 'unique_id': 'ABCDEF1234567890-conductivity_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_conductivity_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool Conductivity module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_conductivity_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_dosing_tank-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_dosing_tank', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Dosing tank', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dosing tank', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dosing_tank', + 'unique_id': 'ABCDEF1234567890-dosing_tank', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_dosing_tank-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'My Pool Dosing tank', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_dosing_tank', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_electrolysis_low-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_electrolysis_low', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electrolysis low', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electrolysis low', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electrolysis_low', + 'unique_id': 'ABCDEF1234567890-electrolysis_low', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_electrolysis_low-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'My Pool Electrolysis low', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_electrolysis_low', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_filtration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_filtration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filtration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filtration', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filtration', + 'unique_id': 'ABCDEF1234567890-filtration', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_filtration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool Filtration', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_filtration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_heating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_heating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Heating', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating', + 'unique_id': 'ABCDEF1234567890-heating', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_heating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool Heating', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_heating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_hidro_cover_reduction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_hidro_cover_reduction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hidro cover reduction', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hidro cover reduction', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_cover_reduction', + 'unique_id': 'ABCDEF1234567890-hidro_cover_reduction', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_hidro_cover_reduction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool Hidro cover reduction', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_hidro_cover_reduction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_hidro_flow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_hidro_flow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hidro flow', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hidro flow', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_flow', + 'unique_id': 'ABCDEF1234567890-hidro_flow', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_hidro_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'My Pool Hidro flow', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_hidro_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_hidro_module-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_hidro_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hidro module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hidro module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_module', + 'unique_id': 'ABCDEF1234567890-hidro_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_hidro_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool Hidro module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_hidro_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_io_module-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_io_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'IO module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'IO module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'io_module', + 'unique_id': 'ABCDEF1234567890-io_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_io_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool IO module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_io_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_ph_acid_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_ph_acid_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH acid pump', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH acid pump', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_acid_pump', + 'unique_id': 'ABCDEF1234567890-ph_acid_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_ph_acid_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool pH acid pump', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_ph_acid_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_ph_base_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_ph_base_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH base pump', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH base pump', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_base_pump', + 'unique_id': 'ABCDEF1234567890-ph_base_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_ph_base_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool pH base pump', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_ph_base_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_ph_module-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_ph_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'pH module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_module', + 'unique_id': 'ABCDEF1234567890-ph_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_ph_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool pH module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_ph_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_ph_pump_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_ph_pump_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH pump alarm', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH pump alarm', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_pump_alarm', + 'unique_id': 'ABCDEF1234567890-ph_pump_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_ph_pump_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'My Pool pH pump alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_ph_pump_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_redox_module-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_redox_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Redox module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Redox module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'redox_module', + 'unique_id': 'ABCDEF1234567890-redox_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_redox_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool Redox module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_redox_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_redox_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_redox_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Redox pump', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Redox pump', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'redox_pump', + 'unique_id': 'ABCDEF1234567890-redox_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_redox_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool Redox pump', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_redox_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/vistapool/test_binary_sensor.py b/tests/components/vistapool/test_binary_sensor.py new file mode 100644 index 00000000000000..fc84e301243758 --- /dev/null +++ b/tests/components/vistapool/test_binary_sensor.py @@ -0,0 +1,170 @@ +"""Tests for the Vistapool binary_sensor platform.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.vistapool.const import DOMAIN +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import ( + MockConfigEntry, + async_load_json_object_fixture, + snapshot_platform, +) + + +@pytest.fixture(autouse=True) +def _only_binary_sensor_platform() -> Generator[None]: + """Restrict integration setup to the binary_sensor platform for these tests.""" + with patch( + "homeassistant.components.vistapool.PLATFORMS", [Platform.BINARY_SENSOR] + ): + yield + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "fixture_name", + [ + pytest.param("pool_data.json", id="default"), + pytest.param("pool_data_all_modules.json", id="all_modules_enabled"), + ], +) +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + fixture_name: str, +) -> None: + """Test binary sensor entities for fixtures covering modules off and on.""" + mock_vistapool_client.fetch_pool_data.return_value = ( + await async_load_json_object_fixture(hass, fixture_name, DOMAIN) + ) + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_binary_sensors_hydrolysis_branch( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test the hydrolysis (non-electrolysis) branch creates the right entity.""" + mock_vistapool_client.fetch_pool_data.return_value = { + "main": {"hasHidro": 1, "version": 1}, + "hidro": {"is_electrolysis": False, "low": 1}, + } + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.my_pool_hydrolysis_low").state == STATE_ON + assert hass.states.get("binary_sensor.my_pool_electrolysis_low") is None + + +async def test_binary_sensors_dosing_tank_low( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test the dosing-tank sensor reports `on` when any installed tank is low.""" + mock_vistapool_client.fetch_pool_data.return_value = { + "main": {"hasPH": 1, "version": 1}, + "modules": {"ph": {"tank": 1}}, + } + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.my_pool_dosing_tank").state == STATE_ON + + +async def test_binary_sensors_dosing_tank_unknown_when_no_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test the dosing-tank sensor reports unknown when no tank values are available.""" + mock_vistapool_client.fetch_pool_data.return_value = { + "main": {"hasPH": 1, "version": 1}, + } + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.my_pool_dosing_tank").state == STATE_UNKNOWN + + +async def test_binary_sensors_string_values( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test the Vistapool API's numeric-as-string values are coerced correctly.""" + mock_vistapool_client.fetch_pool_data.return_value = { + "main": {"version": 1}, + "filtration": {"status": "1"}, + "backwash": {"status": "0"}, + } + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.my_pool_filtration").state == STATE_ON + assert hass.states.get("binary_sensor.my_pool_backwash").state == STATE_OFF + + +async def test_binary_sensors_fl2_requires_hidro( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test hidro_fl2 is not created when hasCL is set but hasHidro is not.""" + mock_vistapool_client.fetch_pool_data.return_value = { + "main": {"hasCL": 1, "hasHidro": 0, "version": 1}, + "modules": {"cl": {"pump_status": 0, "tank": 0}}, + } + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.my_pool_hidro_fl2") is None + assert hass.states.get("binary_sensor.my_pool_chlorine_pump") is not None + + +async def test_binary_sensors_multi_pool( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + mock_pool_data: dict[str, Any], +) -> None: + """Test setup creates binary sensors for every pool on the account.""" + mock_vistapool_client.get_pools.return_value = { + "pool_a": "Pool A", + "pool_b": "Pool B", + } + mock_vistapool_client.fetch_pool_data.return_value = mock_pool_data + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.pool_a_filtration").state == STATE_ON + assert hass.states.get("binary_sensor.pool_b_filtration").state == STATE_ON From e56c221eb1b433cde727ed3aea9a65210c8a2a03 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 11 Jun 2026 14:46:06 +0200 Subject: [PATCH 08/20] Add tests documenting nested event firing behavior (#173491) --- tests/test_core.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index 591147f30c868b..7ab86857b04d50 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1333,6 +1333,60 @@ async def listener(event): assert len(calls) == 1 +async def test_eventbus_nested_fire_dispatch_order(hass: HomeAssistant) -> None: + """Test dispatch order when a listener fires an event synchronously. + + Event dispatch is reentrant: an event fired from within a synchronous + listener is dispatched immediately, nested inside the dispatch of the + outer event. + + The implementation of event listeners is such that listeners are called + in the order they were registered + + As a result, the order in which a listener observes the two + events depends on its registration position relative to the listener + which fires the nested event: listeners registered before it observe + fire order, listeners registered after it observe the nested event + first. + + This test documents the current behavior rather than guarantees it: a + non-reentrant (queued) dispatch would make all listeners observe fire + order. + """ + observed_before: list[str] = [] + observed_after: list[str] = [] + + @ha.callback + def observer_before(event: ha.Event) -> None: + observed_before.append(event.event_type) + + @ha.callback + def fire_nested(event: ha.Event) -> None: + hass.bus.async_fire("test_nested") + + @ha.callback + def observer_after(event: ha.Event) -> None: + observed_after.append(event.event_type) + + unsubs = [ + hass.bus.async_listen("test_outer", observer_before), + hass.bus.async_listen("test_nested", observer_before), + hass.bus.async_listen("test_outer", fire_nested), + hass.bus.async_listen("test_outer", observer_after), + hass.bus.async_listen("test_nested", observer_after), + ] + + hass.bus.async_fire("test_outer") + + # Registered before the nesting listener: observes fire order. + assert observed_before == ["test_outer", "test_nested"] + # Registered after the nesting listener: observes inverted order. + assert observed_after == ["test_nested", "test_outer"] + + for unsub in unsubs: + unsub() + + async def test_eventbus_unsubscribe_listener(hass: HomeAssistant) -> None: """Test unsubscribe listener from returned function.""" calls = [] From 851facd82655040cd318bdb7e98b6a866513b66f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 11 Jun 2026 14:48:02 +0200 Subject: [PATCH 09/20] Reolink UID in the config entry (#173505) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/reolink/__init__.py | 26 +++++++++- .../components/reolink/config_flow.py | 2 + homeassistant/components/reolink/const.py | 1 + homeassistant/components/reolink/host.py | 3 ++ homeassistant/components/reolink/strings.json | 3 ++ tests/components/reolink/conftest.py | 4 ++ tests/components/reolink/test_config_flow.py | 18 +++++++ tests/components/reolink/test_init.py | 51 +++++++++++++++++++ 8 files changed, 107 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index f58eeefc318f9d..64cc0ab486f2dd 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -8,11 +8,16 @@ from typing import Any from reolink_aio.api import DUAL_LENS_DUAL_MOTION_MODELS, RETRY_ATTEMPTS +from reolink_aio.const import UNKNOWN from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -29,6 +34,7 @@ CONF_BC_PORT, CONF_FIRMWARE_CHECK_TIME, CONF_SUPPORTS_PRIVACY_MODE, + CONF_UID, CONF_USE_HTTPS, DOMAIN, ) @@ -95,6 +101,22 @@ async def async_setup_entry( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop) ) + # do not allow changes to the UID + if ( + config_entry.data.get(CONF_UID, host.api.uid) != host.api.uid + and config_entry.data.get(CONF_UID) != UNKNOWN + ): + await host.stop() + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="uid_mismatch", + translation_placeholders={ + "name": host.api.nvr_name, + "conf_uid": config_entry.data.get(CONF_UID, ""), + "uid": host.api.uid, + }, + ) + # update the config info if needed for the next time if ( host.api.port != config_entry.data[CONF_PORT] @@ -105,6 +127,7 @@ async def async_setup_entry( or host.api.baichuan_only != config_entry.data.get(CONF_BC_ONLY) or host.api.baichuan.connection_type.value != config_entry.data.get(CONF_BC_CONNECT) + or host.api.uid != config_entry.data.get(CONF_UID) ): if host.api.port != config_entry.data[CONF_PORT]: _LOGGER.warning( @@ -130,6 +153,7 @@ async def async_setup_entry( CONF_BC_PORT: host.api.baichuan.port, CONF_BC_ONLY: host.api.baichuan_only, CONF_BC_CONNECT: host.api.baichuan.connection_type.value, + CONF_UID: host.api.uid, CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"), } hass.config_entries.async_update_entry(config_entry, data=data) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 357b255eb9169d..f91a102fe429f9 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -41,6 +41,7 @@ CONF_BC_ONLY, CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, + CONF_UID, CONF_USE_HTTPS, DOMAIN, ) @@ -312,6 +313,7 @@ async def async_step_user( user_input[CONF_BC_PORT] = host.api.baichuan.port user_input[CONF_BC_ONLY] = host.api.baichuan_only user_input[CONF_BC_CONNECT] = host.api.baichuan.connection_type.value + user_input[CONF_UID] = host.api.uid user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported( None, "privacy_mode" ) diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index f76d9c4ef18c99..e019822faf75bf 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -10,6 +10,7 @@ CONF_BC_CONNECT = "baichuan_connection" CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported" CONF_FIRMWARE_CHECK_TIME = "firmware_check_time" +CONF_UID = "uid" # Conserve battery by not waking the battery cameras each minute during normal update # Most props are cached in the Home Hub and updated, but some are skipped diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index ab80e1396a2d91..f08868377b05fb 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -11,6 +11,7 @@ from aiohttp.web import Request from reolink_aio.api import ALLOWED_SPECIAL_CHARS, Host from reolink_aio.baichuan import DEFAULT_BC_PORT +from reolink_aio.const import UNKNOWN from reolink_aio.enums import ConnectionEnum, SubType from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError @@ -40,6 +41,7 @@ CONF_BC_ONLY, CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, + CONF_UID, CONF_USE_HTTPS, DOMAIN, ) @@ -105,6 +107,7 @@ def get_aiohttp_session() -> aiohttp.ClientSession: bc_port=config.get(CONF_BC_PORT, DEFAULT_BC_PORT), bc_connection=bc_connection, bc_only=config.get(CONF_BC_ONLY, False), + uid=config.get(CONF_UID, UNKNOWN), ) self.last_wake: defaultdict[int, float] = defaultdict(float) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 8731bfcdcf0782..269fa81f827c69 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -918,6 +918,9 @@ "timeout": { "message": "Timeout waiting on a response: {err}" }, + "uid_mismatch": { + "message": "UID {uid} of Reolink camera \"{name}\" did not match the stored configuration UID {conf_uid}, please check the connection details" + }, "unexpected": { "message": "Unexpected Reolink error: {err}" }, diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 37a2e98dcf1fa0..07d4abb64fdc77 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -10,9 +10,11 @@ from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.const import ( + CONF_BC_CONNECT, CONF_BC_ONLY, CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, + CONF_UID, CONF_USE_HTTPS, DOMAIN, ) @@ -244,6 +246,8 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, CONF_BC_PORT: TEST_BC_PORT, CONF_BC_ONLY: False, + CONF_BC_CONNECT: TEST_BC_CON, + CONF_UID: TEST_UID, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index c643fb20e34fa0..17a79187f319a0 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -23,6 +23,7 @@ CONF_BC_ONLY, CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, + CONF_UID, CONF_USE_HTTPS, DOMAIN, ) @@ -54,6 +55,7 @@ TEST_PASSWORD2, TEST_PORT, TEST_PRIVACY, + TEST_UID, TEST_USE_HTTPS, TEST_USERNAME, TEST_USERNAME2, @@ -96,6 +98,7 @@ async def test_config_flow_manual_success(hass: HomeAssistant) -> None: CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -152,6 +155,7 @@ async def test_config_flow_privacy_success( CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -195,6 +199,7 @@ async def test_config_flow_baichuan_only( CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: True, + CONF_UID: TEST_UID, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -358,6 +363,7 @@ async def test_config_flow_errors(hass: HomeAssistant, reolink_host: MagicMock) CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -379,6 +385,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, }, options={ CONF_PROTOCOL: "rtsp", @@ -421,6 +428,7 @@ async def test_reauth(hass: HomeAssistant) -> None: CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -470,6 +478,7 @@ async def test_reauth_abort_unique_id_mismatch( CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -541,6 +550,7 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -566,6 +576,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -609,6 +620,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( bc_port=TEST_BC_PORT, bc_connection=ConnectionEnum(TEST_BC_CON), bc_only=False, + uid=TEST_UID, ) assert expected_call in reolink_host_class.call_args_list @@ -692,6 +704,7 @@ async def test_dhcp_ip_update( CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -736,6 +749,7 @@ async def test_dhcp_ip_update( bc_port=TEST_BC_PORT, bc_connection=ConnectionEnum(TEST_BC_CON), bc_only=False, + uid=TEST_UID, ) assert expected_call in reolink_host_class.call_args_list @@ -770,6 +784,7 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -804,6 +819,7 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( bc_port=TEST_BC_PORT, bc_connection=ConnectionEnum(TEST_BC_CON), bc_only=False, + uid=TEST_UID, ) assert expected_call in reolink_host_class.call_args_list @@ -834,6 +850,7 @@ async def test_reconfig(hass: HomeAssistant) -> None: CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -884,6 +901,7 @@ async def test_reconfig_abort_unique_id_mismatch( CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index b3674e2266909a..d8e8f8cde54745 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -49,10 +49,13 @@ from homeassistant.setup import async_setup_component from .conftest import ( + CONF_BC_CONNECT, CONF_BC_ONLY, CONF_SUPPORTS_PRIVACY_MODE, + CONF_UID, CONF_USE_HTTPS, DEFAULT_PROTOCOL, + TEST_BC_CON, TEST_BC_PORT, TEST_CAM_MODEL, TEST_CAM_NAME, @@ -969,6 +972,54 @@ async def test_baichuan_port_changed( assert config_entry.data[CONF_BC_PORT] == 8901 +async def test_uid_changed( + hass: HomeAssistant, + reolink_host: MagicMock, +) -> None: + """Test the addition of the UID to the config entry when not initially present.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=format_mac(TEST_MAC), + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: TEST_PORT, + CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_CONNECT: TEST_BC_CON, + CONF_BC_ONLY: False, + }, + options={ + CONF_PROTOCOL: DEFAULT_PROTOCOL, + }, + title=TEST_NVR_NAME, + ) + config_entry.add_to_hass(hass) + + assert CONF_UID not in config_entry.data + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.data[CONF_UID] == TEST_UID + + +async def test_uid_changed_error( + hass: HomeAssistant, + reolink_host: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test a change of the UID is not accepted and results in an error during init.""" + assert config_entry.data[CONF_UID] == TEST_UID + reolink_host.uid = "SOME2OTHER89UID4" + + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.data[CONF_UID] == TEST_UID + + async def test_privacy_mode_on( hass: HomeAssistant, freezer: FrozenDateTimeFactory, From 0dec701acd0e707c6232e4517bcda0cc96e87fa2 Mon Sep 17 00:00:00 2001 From: bkobus-bbx Date: Thu, 11 Jun 2026 14:48:48 +0200 Subject: [PATCH 10/20] Add support for CO2Sensor to Blebox integration (#173507) --- homeassistant/components/blebox/const.py | 10 +++ homeassistant/components/blebox/icons.json | 3 + homeassistant/components/blebox/sensor.py | 16 +++- homeassistant/components/blebox/strings.json | 12 +++ tests/components/blebox/test_sensor.py | 77 +++++++++++++++++++- 5 files changed, 116 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/blebox/const.py b/homeassistant/components/blebox/const.py index ef264becbffb7a..102c74e815812b 100644 --- a/homeassistant/components/blebox/const.py +++ b/homeassistant/components/blebox/const.py @@ -24,3 +24,13 @@ LIGHT_MAX_KELVINS = 6500 # 154 Mireds LIGHT_MIN_KELVINS = 2700 # 370 Mireds + +CO2_LEVEL: dict[int, str] = { + 0: "excellent", + 1: "good", + 2: "acceptable", + 3: "medium", + 4: "poor", + 5: "unhealthy", + 6: "hazardous", +} diff --git a/homeassistant/components/blebox/icons.json b/homeassistant/components/blebox/icons.json index 3c0f5123dbc03c..5e4dcb9081e81d 100644 --- a/homeassistant/components/blebox/icons.json +++ b/homeassistant/components/blebox/icons.json @@ -18,6 +18,9 @@ } }, "sensor": { + "co2_level": { + "default": "mdi:molecule-co2" + }, "open_status": { "default": "mdi:window-open" }, diff --git a/homeassistant/components/blebox/sensor.py b/homeassistant/components/blebox/sensor.py index d41fb6ff07e901..8e1e561cfb07ec 100644 --- a/homeassistant/components/blebox/sensor.py +++ b/homeassistant/components/blebox/sensor.py @@ -14,6 +14,7 @@ ) from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, UnitOfApparentPower, @@ -31,7 +32,7 @@ from homeassistant.helpers.typing import StateType from . import BleBoxConfigEntry -from .const import OPEN_STATUS +from .const import CO2_LEVEL, OPEN_STATUS from .coordinator import BleBoxCoordinator from .entity import BleBoxEntity @@ -149,6 +150,19 @@ class BleBoxSensorEntityDescription(SensorEntityDescription): options=list(OPEN_STATUS.values()), value_fn=lambda v: OPEN_STATUS.get(int(v)) if v is not None else None, ), + BleBoxSensorEntityDescription( + key="co2", + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ), + BleBoxSensorEntityDescription( + key="co2Definition", + translation_key="co2_level", + device_class=SensorDeviceClass.ENUM, + options=list(CO2_LEVEL.values()), + value_fn=lambda v: CO2_LEVEL.get(int(v)) if v is not None else None, + ), ) diff --git a/homeassistant/components/blebox/strings.json b/homeassistant/components/blebox/strings.json index cbe6be653fe471..4240167412107c 100644 --- a/homeassistant/components/blebox/strings.json +++ b/homeassistant/components/blebox/strings.json @@ -37,6 +37,18 @@ }, "entity": { "sensor": { + "co2_level": { + "name": "Carbon dioxide level", + "state": { + "acceptable": "Acceptable", + "excellent": "Excellent", + "good": "Good", + "hazardous": "Hazardous", + "medium": "Medium", + "poor": "Poor", + "unhealthy": "Unhealthy" + } + }, "open_status": { "state": { "ajar": "Ajar", diff --git a/tests/components/blebox/test_sensor.py b/tests/components/blebox/test_sensor.py index 0bce8658e6fea6..3ada992f6de01d 100644 --- a/tests/components/blebox/test_sensor.py +++ b/tests/components/blebox/test_sensor.py @@ -6,7 +6,7 @@ import blebox_uniapi import pytest -from homeassistant.components.blebox.const import OPEN_STATUS +from homeassistant.components.blebox.const import CO2_LEVEL, OPEN_STATUS from homeassistant.components.sensor import ATTR_OPTIONS, SensorDeviceClass from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -231,3 +231,78 @@ def set_none(): state = hass.states.get(entity_id) assert state.state == STATE_UNKNOWN + + +@pytest.fixture(name="co2_definition_sensor") +def co2_definition_sensor_fixture(): + """Return a default co2Definition sensor mock.""" + feature = mock_feature( + "sensors", + blebox_uniapi.sensor.GenericSensor, + unique_id="BleBox-co2Sensor-1afe34db9437-0.co2Definition", + full_name="co2Sensor-0.co2Definition", + device_class="co2Definition", + native_value=None, + ) + product = feature.product + type(product).name = PropertyMock(return_value="My CO2 sensor") + type(product).model = PropertyMock(return_value="co2Sensor") + return (feature, "sensor.my_co2_sensor_co2sensor_0_co2definition") + + +async def test_co2_definition_sensor_init( + co2_definition_sensor, hass: HomeAssistant +) -> None: + """Test co2Definition sensor initial state is unknown.""" + _, entity_id = co2_definition_sensor + await async_setup_entity(hass, entity_id) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM + assert state.attributes[ATTR_OPTIONS] == list(CO2_LEVEL.values()) + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("raw_value", "expected_state"), + [ + pytest.param(0, "excellent", id="0_excellent"), + pytest.param(1, "good", id="1_good"), + pytest.param(2, "acceptable", id="2_acceptable"), + pytest.param(3, "medium", id="3_medium"), + pytest.param(4, "poor", id="4_poor"), + pytest.param(5, "unhealthy", id="5_unhealthy"), + pytest.param(6, "hazardous", id="6_hazardous"), + ], +) +async def test_co2_definition_sensor_value_mapping( + co2_definition_sensor, + hass: HomeAssistant, + raw_value: int, + expected_state: str, +) -> None: + """Test that each raw co2Definition value maps to the correct string state.""" + feature_mock, entity_id = co2_definition_sensor + + feature_mock.native_value = raw_value + await async_setup_entity(hass, entity_id) + + state = hass.states.get(entity_id) + assert state.state == expected_state + assert state.state in CO2_LEVEL.values() + + +async def test_co2_definition_sensor_none_value( + co2_definition_sensor, hass: HomeAssistant +) -> None: + """Test that a None native_value yields an unknown state.""" + feature_mock, entity_id = co2_definition_sensor + + def set_none(): + feature_mock.native_value = None + + feature_mock.async_update = AsyncMock(side_effect=set_none) + await async_setup_entity(hass, entity_id) + + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN From 1e18b77c67b700a86117470f71a50fc30fa22602 Mon Sep 17 00:00:00 2001 From: bkobus-bbx Date: Thu, 11 Jun 2026 14:52:45 +0200 Subject: [PATCH 11/20] Expose SET_TILT_POSITION only for calibrated tilt shutters (#173501) --- homeassistant/components/blebox/cover.py | 6 +++--- tests/components/blebox/test_cover.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/blebox/cover.py b/homeassistant/components/blebox/cover.py index 453030e097893f..0488607963ecd4 100644 --- a/homeassistant/components/blebox/cover.py +++ b/homeassistant/components/blebox/cover.py @@ -90,10 +90,10 @@ def __init__( if feature.has_tilt: self._attr_supported_features |= ( - CoverEntityFeature.SET_TILT_POSITION - | CoverEntityFeature.OPEN_TILT - | CoverEntityFeature.CLOSE_TILT + CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT ) + if feature.is_calibrated: + self._attr_supported_features |= CoverEntityFeature.SET_TILT_POSITION if feature.tilt_only: self._attr_supported_features &= ~( diff --git a/tests/components/blebox/test_cover.py b/tests/components/blebox/test_cover.py index 76cc1394cdc90a..247fb7419f9b60 100644 --- a/tests/components/blebox/test_cover.py +++ b/tests/components/blebox/test_cover.py @@ -54,6 +54,7 @@ def shutterbox_fixture(): state=None, has_stop=True, has_tilt=True, + is_calibrated=True, is_slider=True, is_position_inverted=True, cover_type=None, @@ -452,6 +453,21 @@ async def test_tilt_with_position_supported_features( assert supported_features & CoverEntityFeature.SET_TILT_POSITION +async def test_tilt_not_calibrated_no_set_tilt_position( + shutterbox, hass: HomeAssistant +) -> None: + """Test that SET_TILT_POSITION is absent when tilt is present but not calibrated.""" + feature_mock, entity_id = shutterbox + feature_mock.is_calibrated = False + + await async_setup_entity(hass, entity_id) + + supported_features = hass.states.get(entity_id).attributes[ATTR_SUPPORTED_FEATURES] + assert supported_features & CoverEntityFeature.OPEN_TILT + assert supported_features & CoverEntityFeature.CLOSE_TILT + assert not supported_features & CoverEntityFeature.SET_TILT_POSITION + + @pytest.mark.parametrize("feature", ALL_COVER_FIXTURES, indirect=["feature"]) async def test_update_failure( feature, From f5f80e7080818aaedb918e510cdd3904dfe612cf Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 11 Jun 2026 14:56:39 +0200 Subject: [PATCH 12/20] Add Reolink webhook push diagnostics (#173499) --- homeassistant/components/reolink/host.py | 2 ++ tests/components/reolink/conftest.py | 1 + tests/components/reolink/test_host.py | 7 +++++++ 3 files changed, 10 insertions(+) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index f08868377b05fb..865166aeac738d 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -938,6 +938,8 @@ def _signal_write_ha_state(self, channels: list[int] | None = None) -> None: def event_connection(self) -> str: """Type of connection to receive events.""" if self._api.baichuan.events_active: + if self._api.baichuan.webhook_subscribed: + return "Webhook push" return "TCP push" if self._webhook_reachable: return "ONVIF push" diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 07d4abb64fdc77..25677b8591e557 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -170,6 +170,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.baichuan.port = TEST_BC_PORT host_mock.baichuan.connection_type = ConnectionEnum(TEST_BC_CON) host_mock.baichuan.events_active = False + host_mock.baichuan.webhook_subscribed = False host_mock.baichuan.login_sucess = True host_mock.baichuan.subscribe_events = AsyncMock() host_mock.baichuan.unsubscribe_events = AsyncMock() diff --git a/tests/components/reolink/test_host.py b/tests/components/reolink/test_host.py index 475dc165558773..012552a44c1b8c 100644 --- a/tests/components/reolink/test_host.py +++ b/tests/components/reolink/test_host.py @@ -553,5 +553,12 @@ async def test_diagnostics_event_connection( # set TCP push as active reolink_host.baichuan.events_active = True + reolink_host.baichuan.webhook_subscribed = False diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) assert diag["event connection"] == "TCP push" + + # set Webhook push as active + reolink_host.baichuan.events_active = True + reolink_host.baichuan.webhook_subscribed = True + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert diag["event connection"] == "Webhook push" From 8fed48d8ace84e0e8cb21c3c41dc4209df96e80a Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Thu, 11 Jun 2026 14:57:43 +0200 Subject: [PATCH 13/20] Add sensor platform to openSenseMap (#172765) --- .../components/opensensemap/__init__.py | 2 +- .../components/opensensemap/air_quality.py | 4 +- .../components/opensensemap/coordinator.py | 84 ++- .../components/opensensemap/sensor.py | 156 ++++++ tests/components/opensensemap/conftest.py | 9 + .../opensensemap/fixtures/station.json | 90 +++ .../opensensemap/snapshots/test_sensor.ambr | 517 ++++++++++++++++++ tests/components/opensensemap/test_sensor.py | 152 +++++ 8 files changed, 1007 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/opensensemap/sensor.py create mode 100644 tests/components/opensensemap/snapshots/test_sensor.ambr create mode 100644 tests/components/opensensemap/test_sensor.py diff --git a/homeassistant/components/opensensemap/__init__.py b/homeassistant/components/opensensemap/__init__.py index 85db0613008069..53e6e0ee0c9134 100644 --- a/homeassistant/components/opensensemap/__init__.py +++ b/homeassistant/components/opensensemap/__init__.py @@ -9,7 +9,7 @@ from .const import CONF_STATION_ID from .coordinator import OpenSenseMapConfigEntry, OpenSenseMapCoordinator -PLATFORMS: list[Platform] = [Platform.AIR_QUALITY] +PLATFORMS: list[Platform] = [Platform.AIR_QUALITY, Platform.SENSOR] async def async_setup_entry( diff --git a/homeassistant/components/opensensemap/air_quality.py b/homeassistant/components/opensensemap/air_quality.py index ce3719ebbb436b..45f5a4f015c7bb 100644 --- a/homeassistant/components/opensensemap/air_quality.py +++ b/homeassistant/components/opensensemap/air_quality.py @@ -117,9 +117,9 @@ def __init__( @property def particulate_matter_2_5(self) -> float | None: """Return the particulate matter 2.5 level.""" - return self.coordinator.data.pm2_5 + return self.coordinator.data.pm2_5.value @property def particulate_matter_10(self) -> float | None: """Return the particulate matter 10 level.""" - return self.coordinator.data.pm10 + return self.coordinator.data.pm10.value diff --git a/homeassistant/components/opensensemap/coordinator.py b/homeassistant/components/opensensemap/coordinator.py index fd94363a3f85bd..a4a4c8a76c14f7 100644 --- a/homeassistant/components/opensensemap/coordinator.py +++ b/homeassistant/components/opensensemap/coordinator.py @@ -2,11 +2,13 @@ from dataclasses import dataclass from datetime import timedelta +from typing import NamedTuple -from opensensemap_api import OpenSenseMap +from opensensemap_api import _TITLES, OpenSenseMap from opensensemap_api.exceptions import OpenSenseMapError from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -14,13 +16,68 @@ SCAN_INTERVAL = timedelta(minutes=10) +# Stations report the same phenomenon in different units, but the library +# exposes only values. These map a station's reported unit (normalized to +# lowercase) to the matching Home Assistant unit so values convert correctly. +TEMPERATURE_UNITS: dict[str, str] = { + "°c": UnitOfTemperature.CELSIUS, + "c": UnitOfTemperature.CELSIUS, + "°f": UnitOfTemperature.FAHRENHEIT, + "f": UnitOfTemperature.FAHRENHEIT, +} +WIND_SPEED_UNITS: dict[str, str] = { + "m/s": UnitOfSpeed.METERS_PER_SECOND, + "km/h": UnitOfSpeed.KILOMETERS_PER_HOUR, + "mph": UnitOfSpeed.MILES_PER_HOUR, +} +PRESSURE_UNITS: dict[str, str] = { + "hpa": UnitOfPressure.HPA, + "pa": UnitOfPressure.PA, + "pascal": UnitOfPressure.PA, + "mbar": UnitOfPressure.MBAR, + "kpa": UnitOfPressure.KPA, +} + + +class Measurement(NamedTuple): + """A station measurement paired with its detected unit, if any.""" + + value: float | None + unit: str | None = None + @dataclass(slots=True, frozen=True) class OpenSenseMapStationData: """Immutable measurements for an openSenseMap station.""" - pm2_5: float | None - pm10: float | None + pm2_5: Measurement + pm10: Measurement + pm1_0: Measurement + temperature: Measurement + humidity: Measurement + air_pressure: Measurement + illuminance: Measurement + wind_speed: Measurement + wind_direction: Measurement + + +def _detect_unit( + api: OpenSenseMap, title_key: str, unit_map: dict[str, str] +) -> str | None: + """Return the Home Assistant unit for a phenomenon reported by the station.""" + + # The library resolves a measurement by matching localized sensor titles + # (opensensemap_api._TITLES) and returns the first matching sensor that has a + # value. Mirror that approach to find the matching unit. + for title in (*_TITLES.get(title_key, ()), title_key): + for sensor in api.data.get("sensors", []): + measurement = sensor.get("lastMeasurement") or {} + if ( + sensor.get("title", "").casefold() == title.casefold() + and measurement.get("value") is not None + ): + return unit_map.get((sensor.get("unit") or "").strip().casefold()) + return None type OpenSenseMapConfigEntry = ConfigEntry[OpenSenseMapCoordinator] @@ -55,4 +112,23 @@ async def _async_update_data(self) -> OpenSenseMapStationData: raise UpdateFailed( f"Unable to fetch data from openSenseMap: {err}" ) from err - return OpenSenseMapStationData(pm2_5=self.api.pm2_5, pm10=self.api.pm10) + return OpenSenseMapStationData( + pm2_5=Measurement(self.api.pm2_5), + pm10=Measurement(self.api.pm10), + pm1_0=Measurement(self.api.pm1_0), + temperature=Measurement( + self.api.temperature, + _detect_unit(self.api, "Temperature", TEMPERATURE_UNITS), + ), + humidity=Measurement(self.api.humidity), + air_pressure=Measurement( + self.api.air_pressure, + _detect_unit(self.api, "Air Pressure", PRESSURE_UNITS), + ), + illuminance=Measurement(self.api.illuminance), + wind_speed=Measurement( + self.api.wind_speed, + _detect_unit(self.api, "Wind Speed", WIND_SPEED_UNITS), + ), + wind_direction=Measurement(self.api.wind_direction), + ) diff --git a/homeassistant/components/opensensemap/sensor.py b/homeassistant/components/opensensemap/sensor.py new file mode 100644 index 00000000000000..b93e4c8f9b4f43 --- /dev/null +++ b/homeassistant/components/opensensemap/sensor.py @@ -0,0 +1,156 @@ +"""Support for openSenseMap sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + DEGREE, + LIGHT_LUX, + PERCENTAGE, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_STATION_ID, DOMAIN, INTEGRATION_TITLE +from .coordinator import ( + Measurement, + OpenSenseMapConfigEntry, + OpenSenseMapCoordinator, + OpenSenseMapStationData, +) + + +@dataclass(frozen=True, kw_only=True) +class OpenSenseMapSensorEntityDescription(SensorEntityDescription): + """Describes openSenseMap sensor entities.""" + + value_fn: Callable[[OpenSenseMapStationData], Measurement] + + +SENSOR_DESCRIPTIONS: tuple[OpenSenseMapSensorEntityDescription, ...] = ( + OpenSenseMapSensorEntityDescription( + key="pm2_5", + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.pm2_5, + ), + OpenSenseMapSensorEntityDescription( + key="pm10", + device_class=SensorDeviceClass.PM10, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.pm10, + ), + OpenSenseMapSensorEntityDescription( + key="pm1_0", + device_class=SensorDeviceClass.PM1, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.pm1_0, + ), + OpenSenseMapSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.temperature, + ), + OpenSenseMapSensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.humidity, + ), + OpenSenseMapSensorEntityDescription( + key="air_pressure", + device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, + native_unit_of_measurement=UnitOfPressure.HPA, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.air_pressure, + ), + OpenSenseMapSensorEntityDescription( + key="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.illuminance, + ), + OpenSenseMapSensorEntityDescription( + key="wind_speed", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.wind_speed, + ), + OpenSenseMapSensorEntityDescription( + key="wind_direction", + device_class=SensorDeviceClass.WIND_DIRECTION, + native_unit_of_measurement=DEGREE, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + value_fn=lambda data: data.wind_direction, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OpenSenseMapConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up openSenseMap sensors from a config entry.""" + coordinator = entry.runtime_data + + entities: list[OpenSenseMapSensor] = [] + for description in SENSOR_DESCRIPTIONS: + measurement = description.value_fn(coordinator.data) + if measurement.value is None: + continue + native_unit = measurement.unit or description.native_unit_of_measurement + entities.append(OpenSenseMapSensor(coordinator, description, native_unit)) + async_add_entities(entities) + + +class OpenSenseMapSensor(CoordinatorEntity[OpenSenseMapCoordinator], SensorEntity): + """Sensor entity representing a single measurement from an openSenseMap station.""" + + _attr_attribution = "Data provided by openSenseMap" + _attr_has_entity_name = True + entity_description: OpenSenseMapSensorEntityDescription + + def __init__( + self, + coordinator: OpenSenseMapCoordinator, + description: OpenSenseMapSensorEntityDescription, + native_unit: str | None, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_native_unit_of_measurement = native_unit + station_id = coordinator.config_entry.data[CONF_STATION_ID] + self._attr_unique_id = f"{station_id}_{description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, station_id)}, + manufacturer=INTEGRATION_TITLE, + configuration_url=f"https://opensensemap.org/explore/{station_id}", + ) + + @property + def native_value(self) -> float | str | None: + """Return the latest value reported by the station.""" + return self.entity_description.value_fn(self.coordinator.data).value diff --git a/tests/components/opensensemap/conftest.py b/tests/components/opensensemap/conftest.py index f36bee5f4c404b..e9091565341629 100644 --- a/tests/components/opensensemap/conftest.py +++ b/tests/components/opensensemap/conftest.py @@ -52,6 +52,15 @@ async def mock_opensensemap_api( } instance.pm2_5 = sensor_values.get("PM2.5") instance.pm10 = sensor_values.get("PM10") + instance.pm1_0 = sensor_values.get("PM1.0") + instance.temperature = sensor_values.get("Temperature") + instance.humidity = sensor_values.get("Humidity") + instance.air_pressure = sensor_values.get("Air Pressure") + instance.illuminance = sensor_values.get("Illuminance") + instance.uv = sensor_values.get("UV") + instance.wind_speed = sensor_values.get("Wind Speed") + instance.wind_direction = sensor_values.get("Wind Direction") + instance.precipitation = sensor_values.get("Precipitation") yield instance diff --git a/tests/components/opensensemap/fixtures/station.json b/tests/components/opensensemap/fixtures/station.json index ced77c463807e8..b9102adb431143 100644 --- a/tests/components/opensensemap/fixtures/station.json +++ b/tests/components/opensensemap/fixtures/station.json @@ -29,6 +29,96 @@ "value": "9.17", "createdAt": "2024-01-01T00:00:00.000Z" } + }, + { + "_id": "sensor-pm1", + "title": "PM1.0", + "unit": "µg/m³", + "sensorType": "SDS 011", + "lastMeasurement": { + "value": "3.10", + "createdAt": "2024-01-01T00:00:00.000Z" + } + }, + { + "_id": "sensor-temperature", + "title": "Temperature", + "unit": "°C", + "sensorType": "HDC1080", + "lastMeasurement": { + "value": "21.30", + "createdAt": "2024-01-01T00:00:00.000Z" + } + }, + { + "_id": "sensor-humidity", + "title": "Humidity", + "unit": "%", + "sensorType": "HDC1080", + "lastMeasurement": { + "value": "47.10", + "createdAt": "2024-01-01T00:00:00.000Z" + } + }, + { + "_id": "sensor-pressure", + "title": "Air Pressure", + "unit": "hPa", + "sensorType": "BMP280", + "lastMeasurement": { + "value": "1013.20", + "createdAt": "2024-01-01T00:00:00.000Z" + } + }, + { + "_id": "sensor-illuminance", + "title": "Illuminance", + "unit": "lx", + "sensorType": "TSL45315", + "lastMeasurement": { + "value": "12500.00", + "createdAt": "2024-01-01T00:00:00.000Z" + } + }, + { + "_id": "sensor-uv", + "title": "UV", + "unit": "UV Index", + "sensorType": "VEML6070", + "lastMeasurement": { + "value": "3.40", + "createdAt": "2024-01-01T00:00:00.000Z" + } + }, + { + "_id": "sensor-wind-speed", + "title": "Wind Speed", + "unit": "m/s", + "sensorType": "MISOL", + "lastMeasurement": { + "value": "2.50", + "createdAt": "2024-01-01T00:00:00.000Z" + } + }, + { + "_id": "sensor-wind-direction", + "title": "Wind Direction", + "unit": "°", + "sensorType": "MISOL", + "lastMeasurement": { + "value": "180.00", + "createdAt": "2024-01-01T00:00:00.000Z" + } + }, + { + "_id": "sensor-precipitation", + "title": "Precipitation", + "unit": "mm", + "sensorType": "MISOL", + "lastMeasurement": { + "value": "0.30", + "createdAt": "2024-01-01T00:00:00.000Z" + } } ] } diff --git a/tests/components/opensensemap/snapshots/test_sensor.ambr b/tests/components/opensensemap/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..fca574251dae25 --- /dev/null +++ b/tests/components/opensensemap/snapshots/test_sensor.ambr @@ -0,0 +1,517 @@ +# serializer version: 1 +# name: test_sensors[sensor.test_station_atmospheric_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_station_atmospheric_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Atmospheric pressure', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Atmospheric pressure', + 'platform': 'opensensemap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-station-id_air_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_station_atmospheric_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by openSenseMap', + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Test Station Atmospheric pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_station_atmospheric_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1013.2', + }) +# --- +# name: test_sensors[sensor.test_station_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_station_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Humidity', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'opensensemap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-station-id_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.test_station_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by openSenseMap', + 'device_class': 'humidity', + 'friendly_name': 'Test Station Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_station_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.1', + }) +# --- +# name: test_sensors[sensor.test_station_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_station_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Illuminance', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'opensensemap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-station-id_illuminance', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensors[sensor.test_station_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by openSenseMap', + 'device_class': 'illuminance', + 'friendly_name': 'Test Station Illuminance', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.test_station_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12500.0', + }) +# --- +# name: test_sensors[sensor.test_station_pm1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_station_pm1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PM1', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM1', + 'platform': 'opensensemap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-station-id_pm1_0', + 'unit_of_measurement': 'μg/m³', + }) +# --- +# name: test_sensors[sensor.test_station_pm1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by openSenseMap', + 'device_class': 'pm1', + 'friendly_name': 'Test Station PM1', + 'state_class': , + 'unit_of_measurement': 'μg/m³', + }), + 'context': , + 'entity_id': 'sensor.test_station_pm1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.1', + }) +# --- +# name: test_sensors[sensor.test_station_pm10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_station_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PM10', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'opensensemap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-station-id_pm10', + 'unit_of_measurement': 'μg/m³', + }) +# --- +# name: test_sensors[sensor.test_station_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by openSenseMap', + 'device_class': 'pm10', + 'friendly_name': 'Test Station PM10', + 'state_class': , + 'unit_of_measurement': 'μg/m³', + }), + 'context': , + 'entity_id': 'sensor.test_station_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.17', + }) +# --- +# name: test_sensors[sensor.test_station_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_station_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PM2.5', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'opensensemap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-station-id_pm2_5', + 'unit_of_measurement': 'μg/m³', + }) +# --- +# name: test_sensors[sensor.test_station_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by openSenseMap', + 'device_class': 'pm25', + 'friendly_name': 'Test Station PM2.5', + 'state_class': , + 'unit_of_measurement': 'μg/m³', + }), + 'context': , + 'entity_id': 'sensor.test_station_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.42', + }) +# --- +# name: test_sensors[sensor.test_station_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_station_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'opensensemap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-station-id_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_station_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by openSenseMap', + 'device_class': 'temperature', + 'friendly_name': 'Test Station Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_station_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.3', + }) +# --- +# name: test_sensors[sensor.test_station_wind_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_station_wind_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wind direction', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind direction', + 'platform': 'opensensemap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-station-id_wind_direction', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensors[sensor.test_station_wind_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by openSenseMap', + 'device_class': 'wind_direction', + 'friendly_name': 'Test Station Wind direction', + 'state_class': , + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.test_station_wind_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '180.0', + }) +# --- +# name: test_sensors[sensor.test_station_wind_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_station_wind_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wind speed', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed', + 'platform': 'opensensemap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-station-id_wind_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_station_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by openSenseMap', + 'device_class': 'wind_speed', + 'friendly_name': 'Test Station Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_station_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.0', + }) +# --- diff --git a/tests/components/opensensemap/test_sensor.py b/tests/components/opensensemap/test_sensor.py new file mode 100644 index 00000000000000..ef8465b445fb52 --- /dev/null +++ b/tests/components/opensensemap/test_sensor.py @@ -0,0 +1,152 @@ +"""Tests for the openSenseMap sensor platform.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Restrict the integration to the sensor platform for these tests.""" + with patch("homeassistant.components.opensensemap.PLATFORMS", [Platform.SENSOR]): + yield + + +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_opensensemap_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor state and registry entries via snapshot.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_missing_measurements_omit_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_opensensemap_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensors are not created for measurements absent from the station.""" + mock_opensensemap_api.air_pressure = None + mock_opensensemap_api.illuminance = None + mock_opensensemap_api.wind_speed = None + mock_opensensemap_api.wind_direction = None + mock_opensensemap_api.pm1_0 = None + + 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() + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + keys = { + entry.unique_id.removeprefix(f"{mock_config_entry.unique_id}_") + for entry in entries + } + assert keys == {"pm2_5", "pm10", "temperature", "humidity"} + + +@pytest.mark.parametrize( + ( + "title", + "entity_id", + "station_unit", + "display_unit", + "expected_state", + ), + [ + pytest.param( + "Temperature", + "sensor.test_station_temperature", + "°F", + "°C", + (21.3 - 32) * 5 / 9, + id="temperature_fahrenheit_to_celsius", + ), + pytest.param( + "Wind Speed", + "sensor.test_station_wind_speed", + "km/h", + "km/h", + 2.5, + id="wind_speed_kmh", + ), + pytest.param( + "Air Pressure", + "sensor.test_station_atmospheric_pressure", + "Pa", + "hPa", + 1013.2 / 100, + id="air_pressure_pa_to_hpa", + ), + ], +) +async def test_unit_detection( + hass: HomeAssistant, + mock_opensensemap_api: AsyncMock, + mock_config_entry: MockConfigEntry, + title: str, + entity_id: str, + station_unit: str, + display_unit: str, + expected_state: float, +) -> None: + """Test units are detected from the station and converted for the metric system.""" + # The fixture reports metric units; override one sensor's unit (the values + # used here are the fixture's raw values) so it must be detected and + # converted, e.g. °F -> °C, km/h stays km/h, Pa -> hPa. + for sensor in mock_opensensemap_api.data["sensors"]: + if sensor["title"] == title: + sensor["unit"] = station_unit + break + + 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() + + # Ensure that the station's actual unit is detected and + # the value is correctly converted to HA's display unit. + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["unit_of_measurement"] == display_unit + assert float(state.state) == pytest.approx(expected_state, abs=0.01) + + +async def test_unit_detection_ignores_value_less_sensors( + hass: HomeAssistant, + mock_opensensemap_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unit detection skips value-less sensors, like the library does.""" + # A value-less duplicate would shadow the real °C sensor's unit (yielding °F) + # if detection didn't skip sensors without a measurement value. + mock_opensensemap_api.data["sensors"].insert( + 0, + {"title": "Temperature", "unit": "°F", "lastMeasurement": {}}, + ) + + 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() + + # Ensure that the real °C sensor is picked, not the value-less duplicate. + state = hass.states.get("sensor.test_station_temperature") + assert state is not None + assert state.attributes["unit_of_measurement"] == UnitOfTemperature.CELSIUS + assert float(state.state) == pytest.approx(21.3, abs=0.01) From d7af8ed2b34137d2ef7d64196d43d198519b15ee Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Thu, 11 Jun 2026 15:50:54 +0200 Subject: [PATCH 14/20] Bump mozart_api to 6.2.0.44.0 (#173514) --- .../components/bang_olufsen/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/bang_olufsen/conftest.py | 27 +++++++++++-------- tests/components/bang_olufsen/const.py | 2 +- .../bang_olufsen/test_media_player.py | 2 +- 5 files changed, 20 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/bang_olufsen/manifest.json b/homeassistant/components/bang_olufsen/manifest.json index b6116c6784205f..545d2c33f163bf 100644 --- a/homeassistant/components/bang_olufsen/manifest.json +++ b/homeassistant/components/bang_olufsen/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/bang_olufsen", "integration_type": "device", "iot_class": "local_push", - "requirements": ["mozart-api==5.3.1.108.2"], + "requirements": ["mozart-api==6.2.0.44.0"], "zeroconf": ["_bangolufsen._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 762cf7ee7a2f09..a12f50f7abfeaa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1610,7 +1610,7 @@ motionblindsble==0.1.3 motioneye-client==0.3.14 # homeassistant.components.bang_olufsen -mozart-api==5.3.1.108.2 +mozart-api==6.2.0.44.0 # homeassistant.components.mullvad mullvad-api==1.0.0 diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py index 285c16fd59879f..cc1b41b5824957 100644 --- a/tests/components/bang_olufsen/conftest.py +++ b/tests/components/bang_olufsen/conftest.py @@ -2,6 +2,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch +from uuid import UUID from mozart_api.models import ( Action, @@ -254,8 +255,8 @@ def mock_mozart_client() -> Generator[AsyncMock]: dynamic_list=None, first_child_menu_item_id=None, label="Yle Radio Suomi Helsinki", - next_sibling_menu_item_id="0b4552f8-7ac6-5046-9d44-5410a815b8d6", - parent_menu_item_id="eee0c2d0-2b3a-4899-a708-658475c38926", + next_sibling_menu_item_id=UUID("0b4552f8-7ac6-5046-9d44-5410a815b8d6"), + parent_menu_item_id=UUID("eee0c2d0-2b3a-4899-a708-658475c38926"), available=None, content=ContentItem( categories=["music"], @@ -264,7 +265,7 @@ def mock_mozart_client() -> Generator[AsyncMock]: source=SourceTypeEnum(value="netRadio"), ), fixed=True, - id="b355888b-2cde-5f94-8592-d47b71d52a27", + id=UUID("b355888b-2cde-5f94-8592-d47b71d52a27"), ), # Has "hdmi" as category, so should be included in video sources "b6591565-80f4-4356-bcd9-c92ca247f0a9": RemoteMenuItem( @@ -293,8 +294,8 @@ def mock_mozart_client() -> Generator[AsyncMock]: dynamic_list="none", first_child_menu_item_id=None, label="HDMI A", - next_sibling_menu_item_id="0ba98974-7b1f-40dc-bc48-fbacbb0f1793", - parent_menu_item_id="b66c835b-6b98-4400-8f84-6348043792c7", + next_sibling_menu_item_id=UUID("0ba98974-7b1f-40dc-bc48-fbacbb0f1793"), + parent_menu_item_id=UUID("b66c835b-6b98-4400-8f84-6348043792c7"), available=True, content=ContentItem( categories=["hdmi"], @@ -303,7 +304,7 @@ def mock_mozart_client() -> Generator[AsyncMock]: source=SourceTypeEnum(value="tv"), ), fixed=False, - id="b6591565-80f4-4356-bcd9-c92ca247f0a9", + id=UUID("b6591565-80f4-4356-bcd9-c92ca247f0a9"), ), # The parent remote menu item. Has the TV label and # should therefore not be included in video sources @@ -312,14 +313,14 @@ def mock_mozart_client() -> Generator[AsyncMock]: scene_list=None, disabled=False, dynamic_list="none", - first_child_menu_item_id="b6591565-80f4-4356-bcd9-c92ca247f0a9", + first_child_menu_item_id=UUID("b6591565-80f4-4356-bcd9-c92ca247f0a9"), label="TV", - next_sibling_menu_item_id="0c4547fe-d3cc-4348-a425-473595b8c9fb", + next_sibling_menu_item_id=UUID("0c4547fe-d3cc-4348-a425-473595b8c9fb"), parent_menu_item_id=None, available=True, content=None, fixed=True, - id="b66c835b-6b98-4400-8f84-6348043792c7", + id=UUID("b66c835b-6b98-4400-8f84-6348043792c7"), ), # Has an empty content, so should not be included "64c9da45-3682-44a4-8030-09ed3ef44160": RemoteMenuItem( @@ -330,11 +331,11 @@ def mock_mozart_client() -> Generator[AsyncMock]: first_child_menu_item_id=None, label="ListeningPosition", next_sibling_menu_item_id=None, - parent_menu_item_id="0c4547fe-d3cc-4348-a425-473595b8c9fb", + parent_menu_item_id=UUID("0c4547fe-d3cc-4348-a425-473595b8c9fb"), available=True, content=None, fixed=True, - id="64c9da45-3682-44a4-8030-09ed3ef44160", + id=UUID("64c9da45-3682-44a4-8030-09ed3ef44160"), ), } client.get_beolink_peers = AsyncMock() @@ -343,11 +344,13 @@ def mock_mozart_client() -> Generator[AsyncMock]: friendly_name=TEST_FRIENDLY_NAME_3, jid=TEST_JID_3, ip_address=TEST_HOST_3, + audio_transport="v2", ), BeolinkPeer( friendly_name=TEST_FRIENDLY_NAME_4, jid=TEST_JID_4, ip_address=TEST_HOST_4, + audio_transport="v2", ), ] client.get_beolink_listeners = AsyncMock() @@ -356,11 +359,13 @@ def mock_mozart_client() -> Generator[AsyncMock]: friendly_name=TEST_FRIENDLY_NAME_3, jid=TEST_JID_3, ip_address=TEST_HOST_3, + audio_transport="v2", ), BeolinkPeer( friendly_name=TEST_FRIENDLY_NAME_4, jid=TEST_JID_4, ip_address=TEST_HOST_4, + audio_transport="v2", ), ] diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py index 495ffab5ebd9aa..441d5753653e28 100644 --- a/tests/components/bang_olufsen/const.py +++ b/tests/components/bang_olufsen/const.py @@ -203,7 +203,7 @@ title="HDMI A", source_internal_id="hdmi_1", output_channel_processing="TrueImage", - output_Channels="5.0.2", + output_channels="5.0.2", ) TEST_PLAYBACK_ERROR = PlaybackError(error="Test error") TEST_PLAYBACK_PROGRESS = PlaybackProgress(progress=123) diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index 6d882079553979..3a4d7a0829e08f 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -582,7 +582,7 @@ async def test_async_update_beolink_listener( playback_metadata_callback( PlaybackContentMetadata( remote_leader=BeolinkLeader( - friendly_name=TEST_FRIENDLY_NAME_2, jid=TEST_JID_2 + friendly_name=TEST_FRIENDLY_NAME_2, jid=TEST_JID_2, audio_transport="v2" ) ) ) From ee30f6c085ceda83338b9751d4a7e05c8a7167e8 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 11 Jun 2026 15:52:06 +0200 Subject: [PATCH 15/20] MELCloud Home follow-up PR to refactor small parts (#173515) --- homeassistant/components/melcloud_home/climate.py | 5 ++--- homeassistant/components/melcloud_home/config_flow.py | 4 +++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/melcloud_home/climate.py b/homeassistant/components/melcloud_home/climate.py index 2b1b0871a1e4bd..f4766cc30acafe 100644 --- a/homeassistant/components/melcloud_home/climate.py +++ b/homeassistant/components/melcloud_home/climate.py @@ -103,7 +103,6 @@ def _async_add_new_ata_units(units: list[ATAUnit]) -> None: async_add_entities(ATAClimateEntity(coordinator, unit) for unit in units) def _async_add_new_atw_units(units: list[ATWUnit]) -> None: - # Erwin: create zone 1 for all units, and zone 2 only when the unit supports it. async_add_entities( ATWZoneClimateEntity(coordinator, unit, zone_number) for unit in units @@ -186,12 +185,12 @@ def fan_modes(self) -> list[str]: @property def current_temperature(self) -> float | None: """Return the current room temperature.""" - return self.unit.room_temperature if self.unit else None + return self.unit.room_temperature @property def target_temperature(self) -> float | None: """Return the target temperature.""" - return self.unit.set_temperature if self.unit else None + return self.unit.set_temperature @property def hvac_mode(self) -> HVACMode: diff --git a/homeassistant/components/melcloud_home/config_flow.py b/homeassistant/components/melcloud_home/config_flow.py index 16f25405a58c00..0122ea83906dce 100644 --- a/homeassistant/components/melcloud_home/config_flow.py +++ b/homeassistant/components/melcloud_home/config_flow.py @@ -26,7 +26,9 @@ STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_EMAIL): str, + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig(type=TextSelectorType.EMAIL, autocomplete="username") + ), vol.Required(CONF_PASSWORD): TextSelector( TextSelectorConfig(type=TextSelectorType.PASSWORD) ), From fdb15ce2d741b8524e4d62bf8391091595483139 Mon Sep 17 00:00:00 2001 From: bkobus-bbx Date: Thu, 11 Jun 2026 16:06:07 +0200 Subject: [PATCH 16/20] Add support for inputSensor Blebox devices (#169841) --- .../components/blebox/binary_sensor.py | 3 + tests/components/blebox/test_binary_sensor.py | 70 ++++++++++++++++--- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/blebox/binary_sensor.py b/homeassistant/components/blebox/binary_sensor.py index 6087546ef6b1df..105cc1b2c18dea 100644 --- a/homeassistant/components/blebox/binary_sensor.py +++ b/homeassistant/components/blebox/binary_sensor.py @@ -25,6 +25,9 @@ key="open", device_class=BinarySensorDeviceClass.WINDOW, ), + BinarySensorEntityDescription( + key="input", + ), ) diff --git a/tests/components/blebox/test_binary_sensor.py b/tests/components/blebox/test_binary_sensor.py index 814c06cab05126..06e9021f6579e3 100644 --- a/tests/components/blebox/test_binary_sensor.py +++ b/tests/components/blebox/test_binary_sensor.py @@ -45,23 +45,75 @@ def open_sensor_fixture() -> tuple[AsyncMock, str]: return feature, "binary_sensor.my_open_sensor_opensensor_0_open" +@pytest.fixture(name="inputsensor") +def inputsensor_fixture() -> tuple[AsyncMock, str]: + """Return a default inputSensor fixture.""" + feature: AsyncMock = mock_feature( + "binary_sensors", + blebox_uniapi.binary_sensor.Input, + unique_id="BleBox-inputSensorD-aa11bb22cc33-0.input", + full_name="inputSensorD-0.input", + device_class="input", + ) + product = feature.product + type(product).name = PropertyMock(return_value="My input sensor") + type(product).model = PropertyMock(return_value="inputSensorD") + return feature, "binary_sensor.my_input_sensor_inputsensord_0_input" + + +@pytest.mark.parametrize( + ( + "fixture_name", + "unique_id", + "expected_name", + "expected_device_class", + "expected_state", + "expected_device_name", + ), + [ + pytest.param( + "rainsensor", + "BleBox-windRainSensor-ea68e74f4f49-0.rain", + "My rain sensor windRainSensor-0.rain", + BinarySensorDeviceClass.MOISTURE, + STATE_ON, + "My rain sensor", + id="moisture", + ), + pytest.param( + "inputsensor", + "BleBox-inputSensorD-aa11bb22cc33-0.input", + "My input sensor inputSensorD-0.input", + None, + STATE_ON, + "My input sensor", + id="input", + ), + ], +) async def test_init( - rainsensor: AsyncMock, device_registry: dr.DeviceRegistry, hass: HomeAssistant + hass: HomeAssistant, + fixture_name: str, + unique_id: str, + expected_name: str, + expected_device_class: BinarySensorDeviceClass | None, + expected_state: str, + expected_device_name: str, + device_registry: dr.DeviceRegistry, + request: pytest.FixtureRequest, ) -> None: """Test binary_sensor initialisation.""" - _, entity_id = rainsensor + _, entity_id = request.getfixturevalue(fixture_name) entry = await async_setup_entity(hass, entity_id) - assert entry.unique_id == "BleBox-windRainSensor-ea68e74f4f49-0.rain" + assert entry.unique_id == unique_id state = hass.states.get(entity_id) - assert state.name == "My rain sensor windRainSensor-0.rain" - - assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.MOISTURE - assert state.state == STATE_ON + assert state.name == expected_name + assert state.attributes.get(ATTR_DEVICE_CLASS) == expected_device_class + assert state.state == expected_state device = device_registry.async_get(entry.device_id) - - assert device.name == "My rain sensor" + assert device.name == expected_device_name async def test_open_sensor_init( From dfa40f807e9fe112928d03f249fddbd56330af78 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:15:26 +0200 Subject: [PATCH 17/20] Remove positional message strings when translation_key is set in manual (#173393) --- homeassistant/components/manual/alarm_control_panel.py | 2 -- tests/components/manual/test_alarm_control_panel.py | 9 ++++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index 230df3f3dcec7d..c1b9b8788125f3 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -422,9 +422,7 @@ def _async_validate_code(self, code: str | None, state: str) -> None: }, ) - # pylint: disable-next=home-assistant-exception-message-with-translation raise ServiceValidationError( - "Invalid alarm code provided", translation_domain=DOMAIN, translation_key="invalid_code", ) diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index 5eb77c7a8f61ff..7dbda38ae1aba1 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -240,7 +240,7 @@ async def test_with_invalid_code(hass: HomeAssistant, service, expected_state) - assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED - with pytest.raises(ServiceValidationError, match=r"^Invalid alarm code provided$"): + with pytest.raises(ServiceValidationError) as err: await hass.services.async_call( alarm_control_panel.DOMAIN, service, @@ -250,6 +250,7 @@ async def test_with_invalid_code(hass: HomeAssistant, service, expected_state) - }, blocking=True, ) + assert err.value.translation_key == "invalid_code" assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED @@ -1108,8 +1109,9 @@ async def test_disarm_during_trigger_with_invalid_code(hass: HomeAssistant) -> N assert hass.states.get(entity_id).state == AlarmControlPanelState.PENDING - with pytest.raises(ServiceValidationError, match=r"^Invalid alarm code provided$"): + with pytest.raises(ServiceValidationError) as err: await common.async_alarm_disarm(hass, entity_id=entity_id) + assert err.value.translation_key == "invalid_code" assert hass.states.get(entity_id).state == AlarmControlPanelState.PENDING @@ -1226,8 +1228,9 @@ async def test_disarm_with_template_code(hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert state.state == AlarmControlPanelState.ARMED_HOME - with pytest.raises(ServiceValidationError, match=r"^Invalid alarm code provided$"): + with pytest.raises(ServiceValidationError) as err: await common.async_alarm_disarm(hass, "def") + assert err.value.translation_key == "invalid_code" state = hass.states.get(entity_id) assert state.state == AlarmControlPanelState.ARMED_HOME From ea5e8e798204184af8aa575955a53e0f61f684d4 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 11 Jun 2026 16:31:35 +0200 Subject: [PATCH 18/20] Rephrase aw check requirements (#171676) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/check-requirements.lock.yml | 36 +- .github/workflows/check-requirements.md | 420 +++++++----------- 2 files changed, 169 insertions(+), 287 deletions(-) diff --git a/.github/workflows/check-requirements.lock.yml b/.github/workflows/check-requirements.lock.yml index a80997fa242259..b932dc189912fb 100644 --- a/.github/workflows/check-requirements.lock.yml +++ b/.github/workflows/check-requirements.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"75b8b624ba0c144fb4b28cba143d16a47c30de8afae568fa3256c6febe01a68a","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"} +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"e4fcdd04986da27ef3059faa0cea3d64bb879fe12085ebfdec0041bbc31ec181","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"d3abfe96a194bce3a523ed2093ddedd5704cdf62","version":"v0.74.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.46"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.9","digest":"sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.9@sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} # ___ _ _ # / _ \ | | (_) @@ -59,15 +59,13 @@ permissions: {} concurrency: cancel-in-progress: true - group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }} + group: ${{ github.workflow }}-${{ github.event.workflow_run.id }} run-name: "Check requirements (AW)" jobs: activation: - needs: - - extract_pr_number - - pre_activation + needs: pre_activation # zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation if: > (needs.pre_activation.outputs.activated == 'true') && (github.event_name != 'workflow_run' || github.event.workflow_run.repository.id == github.repository_id && @@ -191,20 +189,20 @@ jobs: run: | bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" { - cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF' + cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF' - GH_AW_PROMPT_198418d99edc7d5b_EOF + GH_AW_PROMPT_2fc32253e89940f3_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF' + cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF' Tools: add_comment, missing_tool, missing_data, noop - GH_AW_PROMPT_198418d99edc7d5b_EOF + GH_AW_PROMPT_2fc32253e89940f3_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" - cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF' + cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF' The following GitHub context information is available for this workflow: {{#if github.actor}} @@ -233,12 +231,12 @@ jobs: {{/if}} - GH_AW_PROMPT_198418d99edc7d5b_EOF + GH_AW_PROMPT_2fc32253e89940f3_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" - cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF' + cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF' {{#runtime-import .github/workflows/check-requirements.md}} - GH_AW_PROMPT_198418d99edc7d5b_EOF + GH_AW_PROMPT_2fc32253e89940f3_EOF } > "$GH_AW_PROMPT" - name: Interpolate variables and render templates uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -323,7 +321,6 @@ jobs: permissions: actions: read contents: read - issues: read pull-requests: read concurrency: group: "gh-aw-copilot-${{ github.workflow }}" @@ -453,9 +450,9 @@ jobs: mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF' + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_eaae5443153d0b45_EOF' {"add_comment":{"max":1,"target":"${{ needs.extract_pr_number.outputs.pr_number }}"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} - GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF + GH_AW_SAFE_OUTPUTS_CONFIG_eaae5443153d0b45_EOF - name: Generate Safe Outputs Tools env: GH_AW_TOOLS_META_JSON: | @@ -647,7 +644,7 @@ jobs: mkdir -p /home/runner/.copilot GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) - cat << GH_AW_MCP_CONFIG_175174907e5a28b4_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + cat << GH_AW_MCP_CONFIG_d99df59573a98681_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" { "mcpServers": { "github": { @@ -657,7 +654,7 @@ jobs: "GITHUB_HOST": "\${GITHUB_SERVER_URL}", "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", "GITHUB_READ_ONLY": "1", - "GITHUB_TOOLSETS": "context,repos,issues,pull_requests,actions" + "GITHUB_TOOLSETS": "repos,pull_requests" }, "guard-policies": { "allow-only": { @@ -691,7 +688,7 @@ jobs: "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" } } - GH_AW_MCP_CONFIG_175174907e5a28b4_EOF + GH_AW_MCP_CONFIG_d99df59573a98681_EOF - name: Mount MCP servers as CLIs id: mount-mcp-clis continue-on-error: true @@ -1284,6 +1281,7 @@ jobs: } extract_pr_number: + needs: activation if: github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/check-requirements.md b/.github/workflows/check-requirements.md index 3174d7ecf6da00..1e96cdede0aa1a 100644 --- a/.github/workflows/check-requirements.md +++ b/.github/workflows/check-requirements.md @@ -6,7 +6,6 @@ on: permissions: contents: read actions: read - issues: read pull-requests: read network: allowed: @@ -14,7 +13,7 @@ network: tools: web-fetch: {} github: - toolsets: [default, actions] + toolsets: [repos, pull_requests] min-integrity: unapproved safe-outputs: add-comment: @@ -44,7 +43,7 @@ jobs: PR=$(jq -r '.pr_number' /tmp/deterministic/results.json) echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}" concurrency: - group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }} + group: ${{ github.workflow }}-${{ github.event.workflow_run.id }} cancel-in-progress: true steps: - name: Download deterministic-results artifact @@ -83,296 +82,181 @@ description: > # Check requirements (AW) -You are a code review assistant for the Home Assistant project. The -deterministic stage has already evaluated every check it can on its own -and produced an artifact containing the PR number, per-package check -results, and a pre-rendered comment with placeholders. **Your only job is -to read that artifact, resolve any `needs_agent` checks, and post the -final comment.** - -## Step 1 — Read the deterministic-stage artifact - -The deterministic stage uploaded its results to the runner at -`/tmp/gh-aw/deterministic/results.json`. - -The JSON has this shape: - -- `pr_number` — the PR being checked. The `add_comment` safe-output is - already targeted at this PR (a pre-job extracts `pr_number` from the - artifact and the workflow wires it into the safe-output config via - `needs.extract_pr_number.outputs.pr_number`), so **you do not need to - set `item_number` yourself** — just emit `add_comment` with the - rendered body. -- `needs_agent` — `true` iff any package's check needs resolution. -- `packages[]` — one entry per changed package. Each entry has: - - `name`, `old_version` (`null` for a newly added package; otherwise the - previous pin), `new_version`, `repo_url`, `publisher_kind`. - - `checks` — a dict keyed by **check kind** (string). Each value has a - `status` (`pass`, `warn`, `fail`, or `needs_agent`) and `details`. -- `rendered_comment` — the final PR comment body, already rendered. For - every check whose status is `needs_agent` it contains two placeholders - you must replace: - - `{{CHECK_CELL::}}` — one cell of the summary - table. Replace with exactly one of `✅`, `⚠️`, `❌`. - - `{{CHECK_DETAIL::}}` — the body of one bullet - in the package's `
` block. Replace with - ` ` (the bullet's leading - `- **