From 393087cf507f416cff677c40159b655933148f77 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 24 Jul 2025 11:50:26 +0200 Subject: [PATCH 01/11] Bump `aioshelly` to 13.8.0 (#149365) --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 08c9163bb3b020..78fc8261bfec80 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "silver", - "requirements": ["aioshelly==13.7.2"], + "requirements": ["aioshelly==13.8.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index e24f1bcd209cc0..cd87da623ba7c5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -384,7 +384,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.7.2 +aioshelly==13.8.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dffabce4fbf486..05469693143f77 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -366,7 +366,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.7.2 +aioshelly==13.8.0 # homeassistant.components.skybell aioskybell==22.7.0 From eea22d8079df800581a137512ad0e3b45e86be33 Mon Sep 17 00:00:00 2001 From: Avery <130164016+avedor@users.noreply.github.com> Date: Thu, 24 Jul 2025 06:29:07 -0400 Subject: [PATCH 02/11] Add config flow for datadog (#148104) Co-authored-by: G Johansson --- homeassistant/components/datadog/__init__.py | 96 +++++--- .../components/datadog/config_flow.py | 185 ++++++++++++++ homeassistant/components/datadog/const.py | 10 + .../components/datadog/manifest.json | 1 + homeassistant/components/datadog/strings.json | 56 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/datadog/common.py | 35 +++ tests/components/datadog/test_config_flow.py | 229 ++++++++++++++++++ tests/components/datadog/test_init.py | 115 +++++++-- 10 files changed, 671 insertions(+), 59 deletions(-) create mode 100644 homeassistant/components/datadog/config_flow.py create mode 100644 homeassistant/components/datadog/const.py create mode 100644 homeassistant/components/datadog/strings.json create mode 100644 tests/components/datadog/common.py create mode 100644 tests/components/datadog/test_config_flow.py diff --git a/homeassistant/components/datadog/__init__.py b/homeassistant/components/datadog/__init__.py index fa852399b090fb..606f34c9ae0a7f 100644 --- a/homeassistant/components/datadog/__init__.py +++ b/homeassistant/components/datadog/__init__.py @@ -2,9 +2,10 @@ import logging -from datadog import initialize, statsd +from datadog import DogStatsd, initialize import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PORT, @@ -17,14 +18,19 @@ from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.typing import ConfigType +from . import config_flow as config_flow +from .const import ( + CONF_RATE, + DEFAULT_HOST, + DEFAULT_PORT, + DEFAULT_PREFIX, + DEFAULT_RATE, + DOMAIN, +) + _LOGGER = logging.getLogger(__name__) -CONF_RATE = "rate" -DEFAULT_HOST = "localhost" -DEFAULT_PORT = 8125 -DEFAULT_PREFIX = "hass" -DEFAULT_RATE = 1 -DOMAIN = "datadog" +type DatadogConfigEntry = ConfigEntry[DogStatsd] CONFIG_SCHEMA = vol.Schema( { @@ -43,63 +49,85 @@ ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Datadog component.""" +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Datadog integration from YAML, initiating config flow import.""" + if DOMAIN not in config: + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config[DOMAIN], + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: DatadogConfigEntry) -> bool: + """Set up Datadog from a config entry.""" - conf = config[DOMAIN] - host = conf[CONF_HOST] - port = conf[CONF_PORT] - sample_rate = conf[CONF_RATE] - prefix = conf[CONF_PREFIX] + data = entry.data + options = entry.options + host = data[CONF_HOST] + port = data[CONF_PORT] + prefix = options[CONF_PREFIX] + sample_rate = options[CONF_RATE] + + statsd_client = DogStatsd(host=host, port=port, namespace=prefix) + entry.runtime_data = statsd_client initialize(statsd_host=host, statsd_port=port) def logbook_entry_listener(event): - """Listen for logbook entries and send them as events.""" name = event.data.get("name") message = event.data.get("message") - statsd.event( + entry.runtime_data.event( title="Home Assistant", - text=f"%%% \n **{name}** {message} \n %%%", + message=f"%%% \n **{name}** {message} \n %%%", tags=[ f"entity:{event.data.get('entity_id')}", f"domain:{event.data.get('domain')}", ], ) - _LOGGER.debug("Sent event %s", event.data.get("entity_id")) - def state_changed_listener(event): - """Listen for new messages on the bus and sends them to Datadog.""" state = event.data.get("new_state") - if state is None or state.state == STATE_UNKNOWN: return - states = dict(state.attributes) metric = f"{prefix}.{state.domain}" tags = [f"entity:{state.entity_id}"] - for key, value in states.items(): - if isinstance(value, (float, int)): - attribute = f"{metric}.{key.replace(' ', '_')}" + for key, value in state.attributes.items(): + if isinstance(value, (float, int, bool)): value = int(value) if isinstance(value, bool) else value - statsd.gauge(attribute, value, sample_rate=sample_rate, tags=tags) - - _LOGGER.debug("Sent metric %s: %s (tags: %s)", attribute, value, tags) + attribute = f"{metric}.{key.replace(' ', '_')}" + entry.runtime_data.gauge( + attribute, value, sample_rate=sample_rate, tags=tags + ) try: value = state_helper.state_as_number(state) + entry.runtime_data.gauge(metric, value, sample_rate=sample_rate, tags=tags) except ValueError: - _LOGGER.debug("Error sending %s: %s (tags: %s)", metric, state.state, tags) - return + pass - statsd.gauge(metric, value, sample_rate=sample_rate, tags=tags) + entry.async_on_unload( + hass.bus.async_listen(EVENT_LOGBOOK_ENTRY, logbook_entry_listener) + ) + entry.async_on_unload( + hass.bus.async_listen(EVENT_STATE_CHANGED, state_changed_listener) + ) - _LOGGER.debug("Sent metric %s: %s (tags: %s)", metric, value, tags) + return True - hass.bus.listen(EVENT_LOGBOOK_ENTRY, logbook_entry_listener) - hass.bus.listen(EVENT_STATE_CHANGED, state_changed_listener) +async def async_unload_entry(hass: HomeAssistant, entry: DatadogConfigEntry) -> bool: + """Unload a Datadog config entry.""" + runtime = entry.runtime_data + runtime.flush() + runtime.close_socket() return True diff --git a/homeassistant/components/datadog/config_flow.py b/homeassistant/components/datadog/config_flow.py new file mode 100644 index 00000000000000..b4486b0967cac3 --- /dev/null +++ b/homeassistant/components/datadog/config_flow.py @@ -0,0 +1,185 @@ +"""Config flow for Datadog.""" + +from typing import Any + +from datadog import DogStatsd +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PREFIX +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import ( + CONF_RATE, + DEFAULT_HOST, + DEFAULT_PORT, + DEFAULT_PREFIX, + DEFAULT_RATE, + DOMAIN, +) + + +class DatadogConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Datadog.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle user config flow.""" + errors: dict[str, str] = {} + if user_input: + # Validate connection to Datadog Agent + success = await validate_datadog_connection( + self.hass, + user_input, + ) + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) + if not success: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title=f"Datadog {user_input['host']}", + data={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + }, + options={ + CONF_PREFIX: user_input[CONF_PREFIX], + CONF_RATE: user_input[CONF_RATE], + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=DEFAULT_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + vol.Required(CONF_PREFIX, default=DEFAULT_PREFIX): str, + vol.Required(CONF_RATE, default=DEFAULT_RATE): int, + } + ), + errors=errors, + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: + """Handle import from configuration.yaml.""" + # Check for duplicates + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) + + result = await self.async_step_user(user_input) + + if errors := result.get("errors"): + await deprecate_yaml_issue(self.hass, False) + return self.async_abort(reason=errors["base"]) + + await deprecate_yaml_issue(self.hass, True) + return result + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Get the options flow handler.""" + return DatadogOptionsFlowHandler() + + +class DatadogOptionsFlowHandler(OptionsFlow): + """Handle Datadog options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the Datadog options.""" + errors: dict[str, str] = {} + data = self.config_entry.data + options = self.config_entry.options + + if user_input is None: + user_input = {} + + success = await validate_datadog_connection( + self.hass, + {**data, **user_input}, + ) + if success: + return self.async_create_entry( + data={ + CONF_PREFIX: user_input[CONF_PREFIX], + CONF_RATE: user_input[CONF_RATE], + } + ) + + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required(CONF_PREFIX, default=options[CONF_PREFIX]): str, + vol.Required(CONF_RATE, default=options[CONF_RATE]): int, + } + ), + errors=errors, + ) + + +async def validate_datadog_connection( + hass: HomeAssistant, user_input: dict[str, Any] +) -> bool: + """Attempt to send a test metric to the Datadog agent.""" + try: + client = DogStatsd(user_input[CONF_HOST], user_input[CONF_PORT]) + await hass.async_add_executor_job(client.increment, "connection_test") + except (OSError, ValueError): + return False + else: + return True + + +async def deprecate_yaml_issue( + hass: HomeAssistant, + import_success: bool, +) -> None: + """Create an issue to deprecate YAML config.""" + if import_success: + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + issue_domain=DOMAIN, + breaks_in_ha_version="2026.2.0", + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Datadog", + }, + ) + else: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_connection_error", + breaks_in_ha_version="2026.2.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_connection_error", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Datadog", + "url": f"/config/integrations/dashboard/add?domain={DOMAIN}", + }, + ) diff --git a/homeassistant/components/datadog/const.py b/homeassistant/components/datadog/const.py new file mode 100644 index 00000000000000..e9e5d80eeba40c --- /dev/null +++ b/homeassistant/components/datadog/const.py @@ -0,0 +1,10 @@ +"""Constants for the Datadog integration.""" + +DOMAIN = "datadog" + +CONF_RATE = "rate" + +DEFAULT_HOST = "localhost" +DEFAULT_PORT = 8125 +DEFAULT_PREFIX = "hass" +DEFAULT_RATE = 1 diff --git a/homeassistant/components/datadog/manifest.json b/homeassistant/components/datadog/manifest.json index ca9681effcaa93..815446b9ab4d86 100644 --- a/homeassistant/components/datadog/manifest.json +++ b/homeassistant/components/datadog/manifest.json @@ -2,6 +2,7 @@ "domain": "datadog", "name": "Datadog", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/datadog", "iot_class": "local_push", "loggers": ["datadog"], diff --git a/homeassistant/components/datadog/strings.json b/homeassistant/components/datadog/strings.json new file mode 100644 index 00000000000000..86bb2019fc1fe1 --- /dev/null +++ b/homeassistant/components/datadog/strings.json @@ -0,0 +1,56 @@ +{ + "config": { + "step": { + "user": { + "description": "Enter your Datadog Agent's address and port.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "prefix": "Prefix", + "rate": "Rate" + }, + "data_description": { + "host": "The hostname or IP address of the Datadog Agent.", + "port": "Port the Datadog Agent is listening on", + "prefix": "Metric prefix to use", + "rate": "The sample rate of UDP packets sent to Datadog." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "options": { + "step": { + "init": { + "description": "Update the Datadog configuration.", + "data": { + "prefix": "[%key:component::datadog::config::step::user::data::prefix%]", + "rate": "[%key:component::datadog::config::step::user::data::rate%]" + }, + "data_description": { + "prefix": "[%key:component::datadog::config::step::user::data_description::prefix%]", + "rate": "[%key:component::datadog::config::step::user::data_description::rate%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "issues": { + "deprecated_yaml_import_connection_error": { + "title": "{domain} YAML configuration import failed", + "description": "There was an error connecting to the Datadog Agent when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the {domain} configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 49695b695ac53d..d9fd32d204bd0d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -124,6 +124,7 @@ "cpuspeed", "crownstone", "daikin", + "datadog", "deako", "deconz", "deluge", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 431ece3f81a6a6..33cc637b8a8603 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1171,7 +1171,7 @@ "datadog": { "name": "Datadog", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_push" }, "ddwrt": { diff --git a/tests/components/datadog/common.py b/tests/components/datadog/common.py new file mode 100644 index 00000000000000..07539dc0e07739 --- /dev/null +++ b/tests/components/datadog/common.py @@ -0,0 +1,35 @@ +"""Common helpers for the datetime entity component tests.""" + +from unittest import mock + +MOCK_DATA = { + "host": "localhost", + "port": 8125, +} + +MOCK_OPTIONS = { + "prefix": "hass", + "rate": 1, +} + +MOCK_CONFIG = {**MOCK_DATA, **MOCK_OPTIONS} + +MOCK_YAML_INVALID = { + "host": "127.0.0.1", + "port": 65535, + "prefix": "failtest", + "rate": 1, +} + + +CONNECTION_TEST_METRIC = "connection_test" + + +def create_mock_state(entity_id, state, attributes=None): + """Helper to create a mock state object.""" + mock_state = mock.MagicMock() + mock_state.entity_id = entity_id + mock_state.state = state + mock_state.domain = entity_id.split(".")[0] + mock_state.attributes = attributes or {} + return mock_state diff --git a/tests/components/datadog/test_config_flow.py b/tests/components/datadog/test_config_flow.py new file mode 100644 index 00000000000000..7950bb2c17d604 --- /dev/null +++ b/tests/components/datadog/test_config_flow.py @@ -0,0 +1,229 @@ +"""Tests for the Datadog config flow.""" + +from unittest.mock import MagicMock, patch + +from homeassistant.components import datadog +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +import homeassistant.helpers.issue_registry as ir + +from .common import MOCK_CONFIG, MOCK_DATA, MOCK_OPTIONS, MOCK_YAML_INVALID + +from tests.common import MockConfigEntry + + +async def test_user_flow_success(hass: HomeAssistant) -> None: + """Test user-initiated config flow.""" + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd" + ) as mock_dogstatsd: + mock_instance = MagicMock() + mock_dogstatsd.return_value = mock_instance + + result = await hass.config_entries.flow.async_init( + datadog.DOMAIN, context={"source": "user"} + ) + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG + ) + assert result2["title"] == f"Datadog {MOCK_CONFIG['host']}" + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["data"] == MOCK_DATA + assert result2["options"] == MOCK_OPTIONS + + +async def test_user_flow_retry_after_connection_fail(hass: HomeAssistant) -> None: + """Test connection failure.""" + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + side_effect=OSError("Connection failed"), + ): + result = await hass.config_entries.flow.async_init( + datadog.DOMAIN, context={"source": "user"} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["data"] == MOCK_DATA + assert result3["options"] == MOCK_OPTIONS + + +async def test_options_flow_cannot_connect(hass: HomeAssistant) -> None: + """Test that the options flow shows an error when connection fails.""" + mock_entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, + ) + mock_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + side_effect=OSError("connection failed"), + ): + result = await hass.config_entries.options.async_init(mock_entry.entry_id) + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=MOCK_OPTIONS + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + ): + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=MOCK_OPTIONS + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["data"] == MOCK_OPTIONS + + +async def test_import_flow( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test import triggers config flow and is accepted.""" + with ( + patch( + "homeassistant.components.datadog.config_flow.DogStatsd" + ) as mock_dogstatsd, + ): + mock_instance = MagicMock() + mock_dogstatsd.return_value = mock_instance + + result = await hass.config_entries.flow.async_init( + datadog.DOMAIN, + context={"source": SOURCE_IMPORT}, + data=MOCK_CONFIG, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == MOCK_DATA + assert result["options"] == MOCK_OPTIONS + + await hass.async_block_till_done() + + # Deprecation issue should be created + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, "deprecated_yaml_datadog" + ) + assert issue is not None + assert issue.translation_key == "deprecated_yaml" + assert issue.severity == ir.IssueSeverity.WARNING + + +async def test_import_connection_error( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test import triggers connection error issue.""" + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + side_effect=OSError("connection refused"), + ): + result = await hass.config_entries.flow.async_init( + datadog.DOMAIN, + context={"source": SOURCE_IMPORT}, + data=MOCK_YAML_INVALID, + ) + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + issue = issue_registry.async_get_issue( + datadog.DOMAIN, "deprecated_yaml_import_connection_error" + ) + assert issue is not None + assert issue.translation_key == "deprecated_yaml_import_connection_error" + assert issue.severity == ir.IssueSeverity.WARNING + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test updating options after setup.""" + mock_entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, + ) + mock_entry.add_to_hass(hass) + + new_options = { + "prefix": "updated", + "rate": 5, + } + + # OSError Case + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + side_effect=OSError, + ): + result = await hass.config_entries.options.async_init(mock_entry.entry_id) + assert result["type"] == FlowResultType.FORM + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=new_options + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + # ValueError Case + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + side_effect=ValueError, + ): + result = await hass.config_entries.options.async_init(mock_entry.entry_id) + assert result["type"] == FlowResultType.FORM + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=new_options + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + # Success Case + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd" + ) as mock_dogstatsd: + mock_instance = MagicMock() + mock_dogstatsd.return_value = mock_instance + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=new_options + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == new_options + mock_instance.increment.assert_called_once_with("connection_test") + + +async def test_import_flow_abort_already_configured_service( + hass: HomeAssistant, +) -> None: + """Abort import if the same host/port is already configured.""" + existing_entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, + ) + existing_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + datadog.DOMAIN, + context={"source": "import"}, + data=MOCK_CONFIG, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" diff --git a/tests/components/datadog/test_init.py b/tests/components/datadog/test_init.py index 3b7bea3c926a04..73bce96d16cff1 100644 --- a/tests/components/datadog/test_init.py +++ b/tests/components/datadog/test_init.py @@ -4,11 +4,15 @@ from unittest.mock import patch from homeassistant.components import datadog -from homeassistant.const import EVENT_LOGBOOK_ENTRY, STATE_OFF, STATE_ON +from homeassistant.components.datadog import async_setup_entry +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_LOGBOOK_ENTRY, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import assert_setup_component +from .common import MOCK_DATA, MOCK_OPTIONS, create_mock_state + +from tests.common import EVENT_STATE_CHANGED, MockConfigEntry, assert_setup_component async def test_invalid_config(hass: HomeAssistant) -> None: @@ -24,20 +28,22 @@ async def test_datadog_setup_full(hass: HomeAssistant) -> None: config = {datadog.DOMAIN: {"host": "host", "port": 123, "rate": 1, "prefix": "foo"}} with ( - patch("homeassistant.components.datadog.initialize") as mock_init, - patch("homeassistant.components.datadog.statsd"), + patch( + "homeassistant.components.datadog.config_flow.DogStatsd" + ) as mock_dogstatsd, ): assert await async_setup_component(hass, datadog.DOMAIN, config) - assert mock_init.call_count == 1 - assert mock_init.call_args == mock.call(statsd_host="host", statsd_port=123) + assert mock_dogstatsd.call_count == 1 + assert mock_dogstatsd.call_args == mock.call("host", 123) async def test_datadog_setup_defaults(hass: HomeAssistant) -> None: """Test setup with defaults.""" with ( - patch("homeassistant.components.datadog.initialize") as mock_init, - patch("homeassistant.components.datadog.statsd"), + patch( + "homeassistant.components.datadog.config_flow.DogStatsd" + ) as mock_dogstatsd, ): assert await async_setup_component( hass, @@ -51,20 +57,31 @@ async def test_datadog_setup_defaults(hass: HomeAssistant) -> None: }, ) - assert mock_init.call_count == 1 - assert mock_init.call_args == mock.call(statsd_host="host", statsd_port=8125) + assert mock_dogstatsd.call_count == 1 + assert mock_dogstatsd.call_args == mock.call("host", 8125) async def test_logbook_entry(hass: HomeAssistant) -> None: """Test event listener.""" with ( - patch("homeassistant.components.datadog.initialize"), - patch("homeassistant.components.datadog.statsd") as mock_statsd, + patch("homeassistant.components.datadog.DogStatsd") as mock_statsd_class, + patch( + "homeassistant.components.datadog.config_flow.DogStatsd", mock_statsd_class + ), ): + mock_statsd = mock_statsd_class.return_value + assert await async_setup_component( hass, datadog.DOMAIN, - {datadog.DOMAIN: {"host": "host", "rate": datadog.DEFAULT_RATE}}, + { + datadog.DOMAIN: { + "host": "host", + "port": datadog.DEFAULT_PORT, + "rate": datadog.DEFAULT_RATE, + "prefix": datadog.DEFAULT_PREFIX, + } + }, ) event = { @@ -79,19 +96,21 @@ async def test_logbook_entry(hass: HomeAssistant) -> None: assert mock_statsd.event.call_count == 1 assert mock_statsd.event.call_args == mock.call( title="Home Assistant", - text=f"%%% \n **{event['name']}** {event['message']} \n %%%", + message=f"%%% \n **{event['name']}** {event['message']} \n %%%", tags=["entity:sensor.foo.bar", "domain:automation"], ) - mock_statsd.event.reset_mock() - async def test_state_changed(hass: HomeAssistant) -> None: """Test event listener.""" with ( - patch("homeassistant.components.datadog.initialize"), - patch("homeassistant.components.datadog.statsd") as mock_statsd, + patch("homeassistant.components.datadog.DogStatsd") as mock_statsd_class, + patch( + "homeassistant.components.datadog.config_flow.DogStatsd", mock_statsd_class + ), ): + mock_statsd = mock_statsd_class.return_value + assert await async_setup_component( hass, datadog.DOMAIN, @@ -109,12 +128,7 @@ async def test_state_changed(hass: HomeAssistant) -> None: attributes = {"elevation": 3.2, "temperature": 5.0, "up": True, "down": False} for in_, out in valid.items(): - state = mock.MagicMock( - domain="sensor", - entity_id="sensor.foobar", - state=in_, - attributes=attributes, - ) + state = create_mock_state("sensor.foobar", in_, attributes) hass.states.async_set(state.entity_id, state.state, state.attributes) await hass.async_block_till_done() assert mock_statsd.gauge.call_count == 5 @@ -145,3 +159,56 @@ async def test_state_changed(hass: HomeAssistant) -> None: hass.states.async_set("domain.test", invalid, {}) await hass.async_block_till_done() assert not mock_statsd.gauge.called + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test unloading the config entry cleans up properly.""" + client = mock.MagicMock() + + with ( + patch("homeassistant.components.datadog.DogStatsd", return_value=client), + patch("homeassistant.components.datadog.initialize"), + ): + entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + + client.flush.assert_called_once() + client.close_socket.assert_called_once() + + +async def test_state_changed_skips_unknown(hass: HomeAssistant) -> None: + """Test state_changed_listener skips None and unknown states.""" + entry = MockConfigEntry(domain=datadog.DOMAIN, data=MOCK_DATA, options=MOCK_OPTIONS) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.datadog.config_flow.DogStatsd" + ) as mock_dogstatsd, + ): + await async_setup_entry(hass, entry) + + # Test None state + hass.bus.async_fire(EVENT_STATE_CHANGED, {"new_state": None}) + await hass.async_block_till_done() + assert not mock_dogstatsd.gauge.called + + # Test STATE_UNKNOWN + unknown_state = mock.MagicMock() + unknown_state.state = STATE_UNKNOWN + hass.bus.async_fire(EVENT_STATE_CHANGED, {"new_state": unknown_state}) + await hass.async_block_till_done() + assert not mock_dogstatsd.gauge.called From f481c1b92f217008958a749f3654f21d84762651 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Thu, 24 Jul 2025 19:33:34 +0900 Subject: [PATCH 03/11] Add sensors for ventilator in LG ThinQ (#140846) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/icons.json | 15 ++++++ homeassistant/components/lg_thinq/sensor.py | 40 +++++++++++++++ .../components/lg_thinq/strings.json | 51 +++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json index 02af1dec155987..303660aef75868 100644 --- a/homeassistant/components/lg_thinq/icons.json +++ b/homeassistant/components/lg_thinq/icons.json @@ -219,6 +219,9 @@ "total_pollution_level": { "default": "mdi:air-filter" }, + "carbon_dioxide": { + "default": "mdi:molecule-co2" + }, "monitoring_enabled": { "default": "mdi:monitor-eye" }, @@ -330,9 +333,21 @@ "hop_oil_info": { "default": "mdi:information-box-outline" }, + "hop_oil_capsule_1": { + "default": "mdi:information-box-outline" + }, + "hop_oil_capsule_2": { + "default": "mdi:information-box-outline" + }, "flavor_info": { "default": "mdi:information-box-outline" }, + "flavor_capsule_1": { + "default": "mdi:information-box-outline" + }, + "flavor_capsule_2": { + "default": "mdi:information-box-outline" + }, "beer_remain": { "default": "mdi:glass-mug-variant" }, diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index 754b07cb2db1f4..44dfd251dc6151 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -75,6 +75,11 @@ device_class=SensorDeviceClass.ENUM, translation_key=ThinQProperty.TOTAL_POLLUTION_LEVEL, ), + ThinQProperty.CO2: SensorEntityDescription( + key=ThinQProperty.CO2, + device_class=SensorDeviceClass.ENUM, + translation_key="carbon_dioxide", + ), } BATTERY_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { ThinQProperty.BATTERY_PERCENT: SensorEntityDescription( @@ -175,10 +180,30 @@ key=ThinQProperty.HOP_OIL_INFO, translation_key=ThinQProperty.HOP_OIL_INFO, ), + ThinQProperty.HOP_OIL_CAPSULE_1: SensorEntityDescription( + key=ThinQProperty.HOP_OIL_CAPSULE_1, + device_class=SensorDeviceClass.ENUM, + translation_key=ThinQProperty.HOP_OIL_CAPSULE_1, + ), + ThinQProperty.HOP_OIL_CAPSULE_2: SensorEntityDescription( + key=ThinQProperty.HOP_OIL_CAPSULE_2, + device_class=SensorDeviceClass.ENUM, + translation_key=ThinQProperty.HOP_OIL_CAPSULE_2, + ), ThinQProperty.FLAVOR_INFO: SensorEntityDescription( key=ThinQProperty.FLAVOR_INFO, translation_key=ThinQProperty.FLAVOR_INFO, ), + ThinQProperty.FLAVOR_CAPSULE_1: SensorEntityDescription( + key=ThinQProperty.FLAVOR_CAPSULE_1, + device_class=SensorDeviceClass.ENUM, + translation_key=ThinQProperty.FLAVOR_CAPSULE_1, + ), + ThinQProperty.FLAVOR_CAPSULE_2: SensorEntityDescription( + key=ThinQProperty.FLAVOR_CAPSULE_2, + device_class=SensorDeviceClass.ENUM, + translation_key=ThinQProperty.FLAVOR_CAPSULE_2, + ), ThinQProperty.BEER_REMAIN: SensorEntityDescription( key=ThinQProperty.BEER_REMAIN, native_unit_of_measurement=PERCENTAGE, @@ -415,6 +440,7 @@ DeviceType.COOKTOP: ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], POWER_SENSOR_DESC[ThinQProperty.POWER_LEVEL], + TIMER_SENSOR_DESC[TimerProperty.REMAIN], ), DeviceType.DEHUMIDIFIER: ( JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE], @@ -435,7 +461,11 @@ RECIPE_SENSOR_DESC[ThinQProperty.WORT_INFO], RECIPE_SENSOR_DESC[ThinQProperty.YEAST_INFO], RECIPE_SENSOR_DESC[ThinQProperty.HOP_OIL_INFO], + RECIPE_SENSOR_DESC[ThinQProperty.HOP_OIL_CAPSULE_1], + RECIPE_SENSOR_DESC[ThinQProperty.HOP_OIL_CAPSULE_2], RECIPE_SENSOR_DESC[ThinQProperty.FLAVOR_INFO], + RECIPE_SENSOR_DESC[ThinQProperty.FLAVOR_CAPSULE_1], + RECIPE_SENSOR_DESC[ThinQProperty.FLAVOR_CAPSULE_2], RECIPE_SENSOR_DESC[ThinQProperty.BEER_REMAIN], RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], ELAPSED_DAY_SENSOR_DESC[ThinQProperty.ELAPSED_DAY_STATE], @@ -497,6 +527,16 @@ TEMPERATURE_SENSOR_DESC[ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE], TEMPERATURE_SENSOR_DESC[ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE], ), + DeviceType.VENTILATOR: ( + AIR_QUALITY_SENSOR_DESC[ThinQProperty.CO2], + AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1], + AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM2], + AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM10], + TEMPERATURE_SENSOR_DESC[ThinQProperty.CURRENT_TEMPERATURE], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP], + TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP], + ), DeviceType.WASHCOMBO_MAIN: WASHER_SENSORS, DeviceType.WASHCOMBO_MINI: WASHER_SENSORS, DeviceType.WASHER: WASHER_SENSORS, diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 402816466ea422..d0972a801273d3 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -333,6 +333,19 @@ "very_bad": "Poor" } }, + "carbon_dioxide": { + "name": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", + "state": { + "invalid": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::invalid%]", + "good": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::good%]", + "normal": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::normal%]", + "moderate": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::normal%]", + "bad": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::bad%]", + "unhealthy": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::bad%]", + "very_bad": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::very_bad%]", + "poor": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::very_bad%]" + } + }, "monitoring_enabled": { "name": "Air quality sensor", "state": { @@ -771,9 +784,47 @@ "hop_oil_info": { "name": "Hops" }, + "hop_oil_capsule_1": { + "name": "First hop", + "state": { + "cascade": "Cascade", + "chinook": "Chinook", + "goldings": "Goldings", + "fuggles": "Fuggles", + "hallertau": "Hallertau", + "citrussy": "Citrussy" + } + }, + "hop_oil_capsule_2": { + "name": "Second hop", + "state": { + "cascade": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::cascade%]", + "chinook": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::chinook%]", + "goldings": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::goldings%]", + "fuggles": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::fuggles%]", + "hallertau": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::hallertau%]", + "citrussy": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::citrussy%]" + } + }, "flavor_info": { "name": "Flavor" }, + "flavor_capsule_1": { + "name": "First flavor", + "state": { + "coriander": "Coriander", + "coriander_seed": "[%key:component::lg_thinq::entity::sensor::flavor_capsule_1::state::coriander%]", + "orange": "Orange" + } + }, + "flavor_capsule_2": { + "name": "Second flavor", + "state": { + "coriander": "[%key:component::lg_thinq::entity::sensor::flavor_capsule_1::state::coriander%]", + "coriander_seed": "[%key:component::lg_thinq::entity::sensor::flavor_capsule_1::state::coriander%]", + "orange": "[%key:component::lg_thinq::entity::sensor::flavor_capsule_1::state::orange%]" + } + }, "beer_remain": { "name": "Recipe progress" }, From feeef8871030eb2701db5d9c7f2413a7e877c10a Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Thu, 24 Jul 2025 12:07:35 +0100 Subject: [PATCH 04/11] Bump aiomealie to 0.10.0 (#149370) --- homeassistant/components/mealie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index 0aa9aa868474a4..804011b3d9a43b 100644 --- a/homeassistant/components/mealie/manifest.json +++ b/homeassistant/components/mealie/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["aiomealie==0.9.6"] + "requirements": ["aiomealie==0.10.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index cd87da623ba7c5..4dd2505efd50ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -310,7 +310,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.9.6 +aiomealie==0.10.0 # homeassistant.components.modern_forms aiomodernforms==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 05469693143f77..4b7b319ca37209 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -292,7 +292,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.9.6 +aiomealie==0.10.0 # homeassistant.components.modern_forms aiomodernforms==0.1.8 From 326bcc3f053d3297fda1c8138a02ce04bbdde65a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Thu, 24 Jul 2025 14:32:51 +0200 Subject: [PATCH 05/11] Update aioairzone-cloud to v0.7.0 (#149369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- .../components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airzone_cloud/conftest.py | 18 ++++++++++++++++-- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 41a823386e1f2f..0747678c5a4682 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.6.16"] + "requirements": ["aioairzone-cloud==0.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4dd2505efd50ac..dab44ec91a8d6e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -179,7 +179,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.16 +aioairzone-cloud==0.7.0 # homeassistant.components.airzone aioairzone==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b7b319ca37209..45b664040a21e8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.16 +aioairzone-cloud==0.7.0 # homeassistant.components.airzone aioairzone==1.0.0 diff --git a/tests/components/airzone_cloud/conftest.py b/tests/components/airzone_cloud/conftest.py index b289efd3fb9647..10388eb63d3b8b 100644 --- a/tests/components/airzone_cloud/conftest.py +++ b/tests/components/airzone_cloud/conftest.py @@ -2,20 +2,34 @@ from unittest.mock import patch +from aioairzone_cloud.cloudapi import AirzoneCloudApi import pytest +class MockAirzoneCloudApi(AirzoneCloudApi): + """Mock AirzoneCloudApi class.""" + + async def mock_update(self: "AirzoneCloudApi"): + """Mock AirzoneCloudApi _update function.""" + await self.update_polling() + + @pytest.fixture(autouse=True) def airzone_cloud_no_websockets(): """Fixture to completely disable Airzone Cloud WebSockets.""" with ( patch( - "homeassistant.components.airzone_cloud.AirzoneCloudApi._update_websockets", - return_value=False, + "homeassistant.components.airzone_cloud.AirzoneCloudApi._update", + side_effect=MockAirzoneCloudApi.mock_update, + autospec=True, ), patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.connect_installation_websockets", return_value=None, ), + patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.update_websockets", + return_value=None, + ), ): yield From fea2ef1ac17cc5a41e23da3fe9fdc3e9cd033b71 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 24 Jul 2025 14:37:01 +0200 Subject: [PATCH 06/11] Bump `imgw_pib` to version 1.5.1 (#149368) --- homeassistant/components/imgw_pib/manifest.json | 2 +- homeassistant/components/imgw_pib/strings.json | 2 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/imgw_pib/snapshots/test_sensor.ambr | 2 ++ 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 79118d10de645b..62a4f41ba1fcb9 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["imgw_pib==1.5.0"] + "requirements": ["imgw_pib==1.5.1"] } diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json index 7adb1673c8a253..d55c134ba3b96b 100644 --- a/homeassistant/components/imgw_pib/strings.json +++ b/homeassistant/components/imgw_pib/strings.json @@ -25,6 +25,7 @@ "name": "Hydrological alert", "state": { "no_alert": "No alert", + "exceeding_the_warning_level": "Exceeding the warning level", "hydrological_drought": "Hydrological drought", "rapid_water_level_rise": "Rapid water level rise" }, @@ -41,6 +42,7 @@ "options": { "state": { "no_alert": "[%key:component::imgw_pib::entity::sensor::hydrological_alert::state::no_alert%]", + "exceeding_the_warning_level": "[%key:component::imgw_pib::entity::sensor::hydrological_alert::state::exceeding_the_warning_level%]", "hydrological_drought": "[%key:component::imgw_pib::entity::sensor::hydrological_alert::state::hydrological_drought%]", "rapid_water_level_rise": "[%key:component::imgw_pib::entity::sensor::hydrological_alert::state::rapid_water_level_rise%]" } diff --git a/requirements_all.txt b/requirements_all.txt index dab44ec91a8d6e..3cecc30d6a89ad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1237,7 +1237,7 @@ ihcsdk==2.8.5 imeon_inverter_api==0.3.14 # homeassistant.components.imgw_pib -imgw_pib==1.5.0 +imgw_pib==1.5.1 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 45b664040a21e8..07514077adc0ad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1071,7 +1071,7 @@ igloohome-api==0.1.1 imeon_inverter_api==0.3.14 # homeassistant.components.imgw_pib -imgw_pib==1.5.0 +imgw_pib==1.5.1 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/tests/components/imgw_pib/snapshots/test_sensor.ambr b/tests/components/imgw_pib/snapshots/test_sensor.ambr index 276ea41eecf28d..cdefd949560c09 100644 --- a/tests/components/imgw_pib/snapshots/test_sensor.ambr +++ b/tests/components/imgw_pib/snapshots/test_sensor.ambr @@ -9,6 +9,7 @@ 'no_alert', 'hydrological_drought', 'rapid_water_level_rise', + 'exceeding_the_warning_level', ]), }), 'config_entry_id': , @@ -51,6 +52,7 @@ 'no_alert', 'hydrological_drought', 'rapid_water_level_rise', + 'exceeding_the_warning_level', ]), 'probability': 80, 'valid_from': datetime.datetime(2024, 4, 27, 7, 0, tzinfo=datetime.timezone.utc), From dd3c9ab3afba11afab0eae5889dc793839e7ace0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 24 Jul 2025 15:34:00 +0200 Subject: [PATCH 07/11] Use OptionsFlowWithReload in mqtt (#149092) --- homeassistant/components/mqtt/__init__.py | 11 ----------- homeassistant/components/mqtt/config_flow.py | 6 +++--- tests/components/mqtt/test_config_flow.py | 16 +++++++--------- 3 files changed, 10 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 9e3dc59f852748..4f00c4da958e19 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -246,14 +246,6 @@ ) -async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle signals of config entry being updated. - - Causes for this is config entry options changing. - """ - await hass.config_entries.async_reload(entry.entry_id) - - @callback def _async_remove_mqtt_issues(hass: HomeAssistant, mqtt_data: MqttData) -> None: """Unregister open config issues.""" @@ -435,9 +427,6 @@ async def _setup_client() -> tuple[MqttData, dict[str, Any]]: mqtt_data.subscriptions_to_restore ) mqtt_data.subscriptions_to_restore = set() - mqtt_data.reload_dispatchers.append( - entry.add_update_listener(_async_config_entry_updated) - ) return (mqtt_data, conf) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 52f00c82c27eea..023872d410c71b 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -52,7 +52,7 @@ ConfigFlow, ConfigFlowResult, ConfigSubentryFlow, - OptionsFlow, + OptionsFlowWithReload, SubentryFlowResult, ) from homeassistant.const import ( @@ -2537,7 +2537,7 @@ async def async_step_hassio_confirm( ) -class MQTTOptionsFlowHandler(OptionsFlow): +class MQTTOptionsFlowHandler(OptionsFlowWithReload): """Handle MQTT options.""" async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: @@ -3353,7 +3353,7 @@ def _validate_pki_file( async def async_get_broker_settings( # noqa: C901 - flow: ConfigFlow | OptionsFlow, + flow: ConfigFlow | OptionsFlowWithReload, fields: OrderedDict[Any, Any], entry_config: MappingProxyType[str, Any] | None, user_input: dict[str, Any] | None, diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index ce0a0c44a798ba..b45a4a66aa9bb8 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -17,7 +17,10 @@ from homeassistant import config_entries from homeassistant.components import mqtt from homeassistant.components.hassio import AddonError -from homeassistant.components.mqtt.config_flow import PWD_NOT_CHANGED +from homeassistant.components.mqtt.config_flow import ( + PWD_NOT_CHANGED, + MQTTOptionsFlowHandler, +) from homeassistant.components.mqtt.util import learn_more_url from homeassistant.config_entries import ConfigSubentry, ConfigSubentryData from homeassistant.const import ( @@ -193,8 +196,8 @@ def mock_ssl_context(mock_context_client_key: bytes) -> Generator[dict[str, Magi @pytest.fixture def mock_reload_after_entry_update() -> Generator[MagicMock]: """Mock out the reload after updating the entry.""" - with patch( - "homeassistant.components.mqtt._async_config_entry_updated" + with patch.object( + MQTTOptionsFlowHandler, "automatic_reload", return_value=False ) as mock_reload: yield mock_reload @@ -1330,11 +1333,11 @@ async def test_keepalive_validation( assert result["reason"] == "reconfigure_successful" +@pytest.mark.usefixtures("mock_reload_after_entry_update") async def test_disable_birth_will( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, mock_try_connection: MagicMock, - mock_reload_after_entry_update: MagicMock, ) -> None: """Test disabling birth and will.""" await mqtt_mock_entry() @@ -1348,7 +1351,6 @@ async def test_disable_birth_will( }, ) await hass.async_block_till_done() - mock_reload_after_entry_update.reset_mock() result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM @@ -1387,10 +1389,6 @@ async def test_disable_birth_will( mqtt.CONF_WILL_MESSAGE: {}, } - await hass.async_block_till_done() - # assert that the entry was reloaded with the new config - assert mock_reload_after_entry_update.call_count == 1 - async def test_invalid_discovery_prefix( hass: HomeAssistant, From d6175fb3835b449f58b3dfaf0b9a3b8da6993632 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 24 Jul 2025 16:05:24 +0200 Subject: [PATCH 08/11] Update mypy-dev to 1.18.0a3 (#149383) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index b0affc56113c03..cc9eff9dc3fb81 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,7 +13,7 @@ freezegun==1.5.2 go2rtc-client==0.2.1 license-expression==30.4.3 mock-open==1.4.0 -mypy-dev==1.18.0a2 +mypy-dev==1.18.0a3 pre-commit==4.2.0 pydantic==2.11.7 pylint==3.3.7 From a0992498c6427ac0b12b4ac64a006b0d19551cc2 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Thu, 24 Jul 2025 16:52:43 +0200 Subject: [PATCH 09/11] Improve removal of stale entities/devices in Husqvarna Automower (#148428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- .../husqvarna_automower/coordinator.py | 199 ++++++++---------- 1 file changed, 93 insertions(+), 106 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 7fc1e628e27e85..91adc8c75ecfcf 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -58,9 +58,6 @@ def __init__( self.new_devices_callbacks: list[Callable[[set[str]], None]] = [] self.new_zones_callbacks: list[Callable[[str, set[str]], None]] = [] self.new_areas_callbacks: list[Callable[[str, set[int]], None]] = [] - self._devices_last_update: set[str] = set() - self._zones_last_update: dict[str, set[str]] = {} - self._areas_last_update: dict[str, set[int]] = {} @override @callback @@ -87,11 +84,15 @@ def _on_data_update(self) -> None: """Handle data updates and process dynamic entity management.""" if self.data is not None: self._async_add_remove_devices() - for mower_id in self.data: - if self.data[mower_id].capabilities.stay_out_zones: - self._async_add_remove_stay_out_zones() - if self.data[mower_id].capabilities.work_areas: - self._async_add_remove_work_areas() + if any( + mower_data.capabilities.stay_out_zones + for mower_data in self.data.values() + ): + self._async_add_remove_stay_out_zones() + if any( + mower_data.capabilities.work_areas for mower_data in self.data.values() + ): + self._async_add_remove_work_areas() @callback def handle_websocket_updates(self, ws_data: MowerDictionary) -> None: @@ -161,44 +162,36 @@ async def client_listen( ) def _async_add_remove_devices(self) -> None: - """Add new device, remove non-existing device.""" + """Add new devices and remove orphaned devices from the registry.""" current_devices = set(self.data) + device_registry = dr.async_get(self.hass) - # Skip update if no changes - if current_devices == self._devices_last_update: - return + registered_devices: set[str] = { + str(mower_id) + for device in device_registry.devices.get_devices_for_config_entry_id( + self.config_entry.entry_id + ) + for domain, mower_id in device.identifiers + if domain == DOMAIN + } - # Process removed devices - removed_devices = self._devices_last_update - current_devices - if removed_devices: - _LOGGER.debug("Removed devices: %s", ", ".join(map(str, removed_devices))) - self._remove_device(removed_devices) + orphaned_devices = registered_devices - current_devices + if orphaned_devices: + _LOGGER.debug("Removing orphaned devices: %s", orphaned_devices) + device_registry = dr.async_get(self.hass) + for mower_id in orphaned_devices: + dev = device_registry.async_get_device(identifiers={(DOMAIN, mower_id)}) + if dev is not None: + device_registry.async_update_device( + device_id=dev.id, + remove_config_entry_id=self.config_entry.entry_id, + ) - # Process new device - new_devices = current_devices - self._devices_last_update + new_devices = current_devices - registered_devices if new_devices: - _LOGGER.debug("New devices found: %s", ", ".join(map(str, new_devices))) - self._add_new_devices(new_devices) - - # Update device state - self._devices_last_update = current_devices - - def _remove_device(self, removed_devices: set[str]) -> None: - """Remove device from the registry.""" - device_registry = dr.async_get(self.hass) - for mower_id in removed_devices: - if device := device_registry.async_get_device( - identifiers={(DOMAIN, str(mower_id))} - ): - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) - - def _add_new_devices(self, new_devices: set[str]) -> None: - """Add new device and trigger callbacks.""" - for mower_callback in self.new_devices_callbacks: - mower_callback(new_devices) + _LOGGER.debug("New devices found: %s", new_devices) + for mower_callback in self.new_devices_callbacks: + mower_callback(new_devices) def _async_add_remove_stay_out_zones(self) -> None: """Add new stay-out zones, remove non-existing stay-out zones.""" @@ -209,42 +202,39 @@ def _async_add_remove_stay_out_zones(self) -> None: and mower_data.stay_out_zones is not None } - if not self._zones_last_update: - self._zones_last_update = current_zones - return - - if current_zones == self._zones_last_update: - return + entity_registry = er.async_get(self.hass) + entries = er.async_entries_for_config_entry( + entity_registry, self.config_entry.entry_id + ) - self._zones_last_update = self._update_stay_out_zones(current_zones) + registered_zones: dict[str, set[str]] = {} + for mower_id in self.data: + registered_zones[mower_id] = set() + for entry in entries: + uid = entry.unique_id + if uid.startswith(f"{mower_id}_") and uid.endswith("_stay_out_zones"): + zone_id = uid.removeprefix(f"{mower_id}_").removesuffix( + "_stay_out_zones" + ) + registered_zones[mower_id].add(zone_id) - def _update_stay_out_zones( - self, current_zones: dict[str, set[str]] - ) -> dict[str, set[str]]: - """Update stay-out zones by adding and removing as needed.""" - new_zones = { - mower_id: zones - self._zones_last_update.get(mower_id, set()) - for mower_id, zones in current_zones.items() - } - removed_zones = { - mower_id: self._zones_last_update.get(mower_id, set()) - zones - for mower_id, zones in current_zones.items() - } + for mower_id, current_ids in current_zones.items(): + known_ids = registered_zones.get(mower_id, set()) - for mower_id, zones in new_zones.items(): - for zone_callback in self.new_zones_callbacks: - zone_callback(mower_id, set(zones)) + new_zones = current_ids - known_ids + removed_zones = known_ids - current_ids - entity_registry = er.async_get(self.hass) - for mower_id, zones in removed_zones.items(): - for entity_entry in er.async_entries_for_config_entry( - entity_registry, self.config_entry.entry_id - ): - for zone in zones: - if entity_entry.unique_id.startswith(f"{mower_id}_{zone}"): - entity_registry.async_remove(entity_entry.entity_id) + if new_zones: + _LOGGER.debug("New stay-out zones: %s", new_zones) + for zone_callback in self.new_zones_callbacks: + zone_callback(mower_id, new_zones) - return current_zones + if removed_zones: + _LOGGER.debug("Removing stay-out zones: %s", removed_zones) + for entry in entries: + for zone_id in removed_zones: + if entry.unique_id == f"{mower_id}_{zone_id}_stay_out_zones": + entity_registry.async_remove(entry.entity_id) def _async_add_remove_work_areas(self) -> None: """Add new work areas, remove non-existing work areas.""" @@ -254,39 +244,36 @@ def _async_add_remove_work_areas(self) -> None: if mower_data.capabilities.work_areas and mower_data.work_areas is not None } - if not self._areas_last_update: - self._areas_last_update = current_areas - return - - if current_areas == self._areas_last_update: - return - - self._areas_last_update = self._update_work_areas(current_areas) - - def _update_work_areas( - self, current_areas: dict[str, set[int]] - ) -> dict[str, set[int]]: - """Update work areas by adding and removing as needed.""" - new_areas = { - mower_id: areas - self._areas_last_update.get(mower_id, set()) - for mower_id, areas in current_areas.items() - } - removed_areas = { - mower_id: self._areas_last_update.get(mower_id, set()) - areas - for mower_id, areas in current_areas.items() - } - - for mower_id, areas in new_areas.items(): - for area_callback in self.new_areas_callbacks: - area_callback(mower_id, set(areas)) - entity_registry = er.async_get(self.hass) - for mower_id, areas in removed_areas.items(): - for entity_entry in er.async_entries_for_config_entry( - entity_registry, self.config_entry.entry_id - ): - for area in areas: - if entity_entry.unique_id.startswith(f"{mower_id}_{area}_"): - entity_registry.async_remove(entity_entry.entity_id) + entries = er.async_entries_for_config_entry( + entity_registry, self.config_entry.entry_id + ) - return current_areas + registered_areas: dict[str, set[int]] = {} + for mower_id in self.data: + registered_areas[mower_id] = set() + for entry in entries: + uid = entry.unique_id + if uid.startswith(f"{mower_id}_") and uid.endswith("_work_area"): + parts = uid.removeprefix(f"{mower_id}_").split("_") + area_id_str = parts[0] if parts else None + if area_id_str and area_id_str.isdigit(): + registered_areas[mower_id].add(int(area_id_str)) + + for mower_id, current_ids in current_areas.items(): + known_ids = registered_areas.get(mower_id, set()) + + new_areas = current_ids - known_ids + removed_areas = known_ids - current_ids + + if new_areas: + _LOGGER.debug("New work areas: %s", new_areas) + for area_callback in self.new_areas_callbacks: + area_callback(mower_id, new_areas) + + if removed_areas: + _LOGGER.debug("Removing work areas: %s", removed_areas) + for entry in entries: + for area_id in removed_areas: + if entry.unique_id.startswith(f"{mower_id}_{area_id}_"): + entity_registry.async_remove(entry.entity_id) From 6adcd3452151d77b2359f612431ca9117c1ee3cc Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 17:10:46 +0200 Subject: [PATCH 10/11] Remove space character from "autodetect" in `xiaomi_miio` (#149381) --- homeassistant/components/xiaomi_miio/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index fef185daf41fe2..00e112246496d6 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -311,7 +311,7 @@ "name": "Learn mode" }, "auto_detect": { - "name": "Auto detect" + "name": "Autodetect" }, "ionizer": { "name": "Ionizer" From 760b69d458b6b162c67d9665b28ebdbc18334152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20Wickstr=C3=B6m?= Date: Thu, 24 Jul 2025 18:13:54 +0300 Subject: [PATCH 11/11] Only send integers when setting Huum sauna temperature (#149380) --- homeassistant/components/huum/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index 6a50137f0a70a3..af4e8cc3623ff1 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -89,7 +89,10 @@ def target_temperature(self) -> int: async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" if hvac_mode == HVACMode.HEAT: - await self._turn_on(self.target_temperature) + # Make sure to send integers + # The temperature is not always an integer if the user uses Fahrenheit + temperature = int(self.target_temperature) + await self._turn_on(temperature) elif hvac_mode == HVACMode.OFF: await self.coordinator.huum.turn_off() await self.coordinator.async_refresh() @@ -99,6 +102,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None or self.hvac_mode != HVACMode.HEAT: return + temperature = int(temperature) await self._turn_on(temperature) await self.coordinator.async_refresh()