From 2f1dd3a817c9ff5d8ac9dcb426a4f7da63028216 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 5 May 2026 13:43:18 +0200 Subject: [PATCH 01/11] Deprecate MQTT protocol versions 3.x and migrate to version 5 (#169759) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/mqtt/__init__.py | 30 ++- homeassistant/components/mqtt/client.py | 12 +- homeassistant/components/mqtt/config_flow.py | 5 + homeassistant/components/mqtt/const.py | 4 +- homeassistant/components/mqtt/repairs.py | 71 ++++++- homeassistant/components/mqtt/strings.json | 14 ++ tests/components/mqtt/test_config_flow.py | 28 ++- tests/components/mqtt/test_diagnostics.py | 9 +- tests/components/mqtt/test_mixins.py | 2 +- tests/components/mqtt/test_repairs.py | 194 ++++++++++++++++++- tests/conftest.py | 5 +- 11 files changed, 346 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index d5e6a0279a384..665f343128687 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -11,7 +11,12 @@ from homeassistant import config as conf_util from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DISCOVERY, CONF_PLATFORM, SERVICE_RELOAD +from homeassistant.const import ( + CONF_DISCOVERY, + CONF_PLATFORM, + CONF_PROTOCOL, + SERVICE_RELOAD, +) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ( ConfigValidationError, @@ -27,6 +32,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import async_get_platforms +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType @@ -73,12 +79,14 @@ DEFAULT_DISCOVERY, DEFAULT_ENCODING, DEFAULT_PREFIX, + DEFAULT_PROTOCOL, DEFAULT_QOS, DEFAULT_RETAIN, DOMAIN, ENTITY_PLATFORMS, ENTRY_OPTION_FIELDS, MQTT_CONNECTION_STATE, + PROTOCOL_311, TEMPLATE_ERRORS, Platform, ) @@ -424,6 +432,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load a config entry.""" mqtt_data: MqttData + if (protocol := entry.data.get(CONF_PROTOCOL, PROTOCOL_311)) != DEFAULT_PROTOCOL: + broker: str = entry.data[CONF_BROKER] + async_create_issue( + hass, + DOMAIN, + "protocol_5_migration", + issue_domain=DOMAIN, + is_fixable=True, + breaks_in_ha_version="2027.1.0", + severity=IssueSeverity.WARNING, + learn_more_url="https://www.home-assistant.io/integrations/mqtt/#mqtt-protocol", + data={ + "entry_id": entry.entry_id, + "broker": broker, + "protocol": protocol, + }, + translation_placeholders={"broker": broker, "protocol": protocol}, + translation_key="protocol_5_migration", + ) + async def _setup_client() -> tuple[MqttData, dict[str, Any]]: """Set up the MQTT client.""" # Fetch configuration diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 74faf5a2ff829..d114e0055fcd5 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -63,7 +63,6 @@ DEFAULT_ENCODING, DEFAULT_KEEPALIVE, DEFAULT_PORT, - DEFAULT_PROTOCOL, DEFAULT_QOS, DEFAULT_TRANSPORT, DEFAULT_WILL, @@ -74,6 +73,7 @@ MQTT_PROCESSED_SUBSCRIPTIONS, PROTOCOL_5, PROTOCOL_31, + PROTOCOL_311, TRANSPORT_WEBSOCKETS, ) from .models import ( @@ -331,7 +331,10 @@ def setup(self) -> None: config = self._config clean_session: bool | None = None - if (protocol := config.get(CONF_PROTOCOL, DEFAULT_PROTOCOL)) == PROTOCOL_31: + # If no protocol setting is set in the config entry data + # we assume the config was migrated from YAML, and the + # protocol version is defaulting to legacy version 3.1.1. + if (protocol := config.get(CONF_PROTOCOL, PROTOCOL_311)) == PROTOCOL_31: proto = mqtt.MQTTv31 clean_session = True elif protocol == PROTOCOL_5: @@ -420,7 +423,10 @@ def __init__( self.loop = hass.loop self.config_entry = config_entry self.conf = conf - self.is_mqttv5 = conf.get(CONF_PROTOCOL, DEFAULT_PROTOCOL) == PROTOCOL_5 + # If no protocol setting is set in the config entry data + # we assume the config was migrated from YAML, and the + # protocol version is defaulting to legacy version 3.1.1. + self.is_mqttv5 = conf.get(CONF_PROTOCOL, PROTOCOL_311) == PROTOCOL_5 self._simple_subscriptions: defaultdict[str, set[Subscription]] = defaultdict( set diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 7b31e210dea4c..cb3c3b6d8f47a 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -4073,6 +4073,7 @@ async def _async_get_config_and_try(self) -> dict[str, Any] | None: config: dict[str, Any] = { CONF_BROKER: addon_discovery_config[CONF_HOST], CONF_PORT: addon_discovery_config[CONF_PORT], + CONF_PROTOCOL: DEFAULT_PROTOCOL, CONF_USERNAME: addon_discovery_config.get(CONF_USERNAME), CONF_PASSWORD: addon_discovery_config.get(CONF_PASSWORD), CONF_DISCOVERY: DEFAULT_DISCOVERY, @@ -4301,6 +4302,7 @@ async def async_step_hassio_confirm( if user_input is not None: data: dict[str, Any] = self._hassio_discovery.copy() data[CONF_BROKER] = data.pop(CONF_HOST) + data[CONF_PROTOCOL] = DEFAULT_PROTOCOL can_connect = await self.hass.async_add_executor_job( try_connection, data, @@ -4312,6 +4314,7 @@ async def async_step_hassio_confirm( data={ CONF_BROKER: data[CONF_BROKER], CONF_PORT: data[CONF_PORT], + CONF_PROTOCOL: DEFAULT_PROTOCOL, CONF_USERNAME: data.get(CONF_USERNAME), CONF_PASSWORD: data.get(CONF_PASSWORD), CONF_DISCOVERY: DEFAULT_DISCOVERY, @@ -5178,6 +5181,8 @@ async def _async_validate_broker_settings( ) -> bool: """Additional validation on broker settings for better error messages.""" + if CONF_PROTOCOL not in validated_user_input: + validated_user_input[CONF_PROTOCOL] = DEFAULT_PROTOCOL # Get current certificate settings from config entry certificate: str | None = ( "auto" diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 1e163c6d41cc4..22fdd9178b2e3 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -347,14 +347,14 @@ PROTOCOL_31 = "3.1" PROTOCOL_311 = "3.1.1" PROTOCOL_5 = "5" -SUPPORTED_PROTOCOLS = [PROTOCOL_31, PROTOCOL_311, PROTOCOL_5] +SUPPORTED_PROTOCOLS = [PROTOCOL_5, PROTOCOL_311, PROTOCOL_31] TRANSPORT_TCP = "tcp" TRANSPORT_WEBSOCKETS = "websockets" DEFAULT_PORT = 1883 DEFAULT_KEEPALIVE = 60 -DEFAULT_PROTOCOL = PROTOCOL_311 +DEFAULT_PROTOCOL = PROTOCOL_5 DEFAULT_TRANSPORT = TRANSPORT_TCP DEFAULT_BIRTH = { diff --git a/homeassistant/components/mqtt/repairs.py b/homeassistant/components/mqtt/repairs.py index 8837bd42c48d4..8538515118e8a 100644 --- a/homeassistant/components/mqtt/repairs.py +++ b/homeassistant/components/mqtt/repairs.py @@ -6,10 +6,16 @@ from homeassistant import data_entry_flow from homeassistant.components.repairs import RepairsFlow +from homeassistant.const import CONF_PORT, CONF_PROTOCOL from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .const import DOMAIN +from .config_flow import try_connection +from .const import DEFAULT_PORT, DOMAIN, PROTOCOL_5 + +URL_MQTT_BROKER_CONFIGURATION = ( + "https://www.home-assistant.io/integrations/mqtt/#broker-configuration" +) class MQTTDeviceEntryMigration(RepairsFlow): @@ -50,6 +56,55 @@ async def async_step_confirm( ) +class MQTTProtocolV5Migration(RepairsFlow): + """Handler to migrate to MQTT protocol version 5.""" + + def __init__(self, entry_id: str, broker: str, protocol: str) -> None: + """Initialize the flow.""" + self.entry_id = entry_id + self.broker = broker + self.protocol = protocol + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + entry = self.hass.config_entries.async_get_entry(self.entry_id) + if TYPE_CHECKING: + assert entry is not None + new_entry_data = entry.data.copy() + new_entry_data[CONF_PROTOCOL] = PROTOCOL_5 + # Try the connection with protocol version 5 + if await self.hass.async_add_executor_job( + try_connection, + {CONF_PORT: DEFAULT_PORT} | new_entry_data, + ): + self.hass.config_entries.async_update_entry(entry, data=new_entry_data) + return self.async_create_entry(data={}) + + return self.async_abort( + reason="mqtt_broker_migration_to_v5_failed", + description_placeholders={ + "broker": self.broker, + "protocol": self.protocol, + "url_mqtt_broker_configuration": URL_MQTT_BROKER_CONFIGURATION, + }, + ) + + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders={"broker": self.broker, "protocol": self.protocol}, + ) + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, @@ -58,13 +113,13 @@ async def async_create_fix_flow( """Create flow.""" if TYPE_CHECKING: assert data is not None - entry_id = data["entry_id"] - subentry_id = data["subentry_id"] - name = data["name"] - if TYPE_CHECKING: - assert isinstance(entry_id, str) - assert isinstance(subentry_id, str) - assert isinstance(name, str) + entry_id: str = data["entry_id"] # type: ignore[assignment] + if issue_id == "protocol_5_migration": + broker: str = data["broker"] # type: ignore[assignment] + protocol: str = data["protocol"] # type: ignore[assignment] + return MQTTProtocolV5Migration(entry_id, broker, protocol) + subentry_id: str = data["subentry_id"] # type: ignore[assignment] + name: str = data["name"] # type: ignore[assignment] return MQTTDeviceEntryMigration( entry_id=entry_id, subentry_id=subentry_id, diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 0b0ca17c994e9..a6585b3b4b60b 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1120,6 +1120,20 @@ "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/config/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue.", "title": "Invalid config found for MQTT {domain} item" }, + "protocol_5_migration": { + "fix_flow": { + "abort": { + "mqtt_broker_migration_to_v5_failed": "Migrating the broker ({broker}) protocol version from {protocol} to 5 failed, and the migration has been aborted.\n\nYour broker may not support MQTT protocol version 5.\n\nPlease [reconfigure your MQTT broker settings]({url_mqtt_broker_configuration}) or upgrade your broker to support MQTT protocol version 5 to fix this issue." + }, + "step": { + "confirm": { + "description": "Home Assistant is migrating to MQTT protocol version 5. The currently configured protocol version for broker {broker} is {protocol}. This protocol version is deprecated, and support for it will be removed.\n\nSubmitting this form will try to migrate your MQTT broker configuration to use protocol version 5 to fix this issue.", + "title": "MQTT protocol change required" + } + } + }, + "title": "Deprecated MQTT protocol {protocol} in use" + }, "subentry_migration_discovery": { "fix_flow": { "step": { diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index e6204b02633dd..3a5c6df408d46 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -385,6 +385,7 @@ async def test_user_connection_works( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].data == { "broker": "127.0.0.1", + "protocol": "5", "port": 1883, } # Check we have the latest Config Entry version @@ -427,6 +428,7 @@ async def test_user_connection_works_with_supervisor( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].data == { "broker": "127.0.0.1", + "protocol": "5", "port": 1883, } # Check we tried the connection @@ -525,12 +527,14 @@ async def test_manual_config_set( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].data == { "broker": "127.0.0.1", + "protocol": "5", "port": 1883, } # Check we tried the connection, with precedence for config entry settings mock_try_connection.assert_called_once_with( { "broker": "127.0.0.1", + "protocol": "5", "port": 1883, }, ) @@ -627,6 +631,7 @@ async def test_hassio_confirm( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].data == { "broker": "core-mosquitto", + "protocol": "5", "port": 1883, "username": "mock-user", "password": "mock-pass", @@ -722,6 +727,7 @@ async def test_addon_flow_with_supervisor_addon_running( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].data == { "broker": "core-mosquitto", + "protocol": "5", "port": 1883, "username": "mock-user", "password": "mock-pass", @@ -789,6 +795,7 @@ async def test_addon_flow_with_supervisor_addon_installed( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].data == { "broker": "core-mosquitto", + "protocol": "5", "port": 1883, "username": "mock-user", "password": "mock-pass", @@ -1028,6 +1035,7 @@ async def test_addon_flow_with_supervisor_addon_not_installed( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].data == { "broker": "core-mosquitto", + "protocol": "5", "port": 1883, "username": "mock-user", "password": "mock-pass", @@ -1122,7 +1130,10 @@ async def test_option_flow( assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() await hass.async_block_till_done(wait_background_tasks=True) - assert config_entry.data == {mqtt.CONF_BROKER: "mock-broker"} + assert config_entry.data == { + mqtt.CONF_BROKER: "mock-broker", + CONF_PROTOCOL: "5", + } assert config_entry.options == { mqtt.CONF_DISCOVERY: True, mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", @@ -2004,6 +2015,7 @@ async def test_try_connection_with_advanced_parameters( config_entry, data={ mqtt.CONF_BROKER: "test-broker", + CONF_PROTOCOL: "5", CONF_PORT: 1234, CONF_USERNAME: "user", CONF_PASSWORD: "pass", @@ -2045,7 +2057,7 @@ async def test_try_connection_with_advanced_parameters( CONF_USERNAME: "user", CONF_PASSWORD: PWD_NOT_CHANGED, mqtt.CONF_TLS_INSECURE: True, - CONF_PROTOCOL: "3.1.1", + CONF_PROTOCOL: "5", mqtt.CONF_TRANSPORT: "websockets", mqtt.CONF_WS_PATH: "/path/", mqtt.CONF_WS_HEADERS: '{"h1":"v1","h2":"v2"}', @@ -2140,6 +2152,7 @@ async def test_setup_with_advanced_settings( config_entry, data={ mqtt.CONF_BROKER: "test-broker", + CONF_PROTOCOL: "5", CONF_PORT: 1234, }, ) @@ -2257,6 +2270,7 @@ async def test_setup_with_advanced_settings( # Check config entry result assert config_entry.data == { mqtt.CONF_BROKER: "test-broker", + CONF_PROTOCOL: "5", CONF_PORT: 2345, CONF_USERNAME: "user", CONF_PASSWORD: "secret", @@ -2319,6 +2333,7 @@ async def test_setup_with_certificates( config_entry, data={ mqtt.CONF_BROKER: "test-broker", + CONF_PROTOCOL: "5", CONF_PORT: 1234, }, ) @@ -2366,7 +2381,7 @@ async def test_setup_with_certificates( "set_ca_cert": "custom", "set_client_cert": True, mqtt.CONF_TLS_INSECURE: False, - CONF_PROTOCOL: "3.1.1", + CONF_PROTOCOL: "5", mqtt.CONF_TRANSPORT: "tcp", }, ) @@ -2411,6 +2426,7 @@ async def test_setup_with_certificates( # Check config entry result assert config_entry.data == { mqtt.CONF_BROKER: "test-broker", + CONF_PROTOCOL: "5", CONF_PORT: 2345, CONF_USERNAME: "user", CONF_PASSWORD: "secret", @@ -2439,6 +2455,7 @@ async def test_change_websockets_transport_to_tcp( data={ mqtt.CONF_BROKER: "test-broker", CONF_PORT: 1234, + CONF_PROTOCOL: "5", mqtt.CONF_TRANSPORT: "websockets", mqtt.CONF_WS_HEADERS: {"header_1": "custom_header1"}, mqtt.CONF_WS_PATH: "/some_path", @@ -2472,6 +2489,7 @@ async def test_change_websockets_transport_to_tcp( assert config_entry.data == { mqtt.CONF_BROKER: "test-broker", CONF_PORT: 1234, + CONF_PROTOCOL: "5", mqtt.CONF_TRANSPORT: "tcp", } @@ -2507,6 +2525,7 @@ async def test_reconfigure_flow_form( user_input={ mqtt.CONF_BROKER: "10.10.10,10", CONF_PORT: 1234, + CONF_PROTOCOL: "5", mqtt.CONF_TRANSPORT: "websockets", mqtt.CONF_WS_HEADERS: '{"header_1": "custom_header1"}', mqtt.CONF_WS_PATH: "/some_new_path", @@ -2518,6 +2537,7 @@ async def test_reconfigure_flow_form( assert entry.data == { mqtt.CONF_BROKER: "10.10.10,10", CONF_PORT: 1234, + CONF_PROTOCOL: "5", mqtt.CONF_TRANSPORT: "websockets", mqtt.CONF_WS_HEADERS: {"header_1": "custom_header1"}, mqtt.CONF_WS_PATH: "/some_new_path", @@ -2534,6 +2554,7 @@ async def test_reconfigure_flow_form( CONF_USERNAME: "mqtt-user", CONF_PASSWORD: "mqtt-password", CONF_PORT: 1234, + CONF_PROTOCOL: "5", mqtt.CONF_TRANSPORT: "websockets", mqtt.CONF_WS_HEADERS: {"header_1": "custom_header1"}, mqtt.CONF_WS_PATH: "/some_path", @@ -2573,6 +2594,7 @@ async def test_reconfigure_no_changed_password( CONF_USERNAME: "mqtt-user", CONF_PASSWORD: "mqtt-password", CONF_PORT: 1234, + CONF_PROTOCOL: "5", mqtt.CONF_TRANSPORT: "websockets", mqtt.CONF_WS_HEADERS: {"header_1": "custom_header1"}, mqtt.CONF_WS_PATH: "/some_new_path", diff --git a/tests/components/mqtt/test_diagnostics.py b/tests/components/mqtt/test_diagnostics.py index c669a86b11262..d39816752ad10 100644 --- a/tests/components/mqtt/test_diagnostics.py +++ b/tests/components/mqtt/test_diagnostics.py @@ -17,12 +17,8 @@ ) from tests.typing import ClientSessionGenerator, MqttMockHAClientGenerator -default_entry_data = { - "broker": "mock-broker", -} -default_entry_options = { - "birth_message": {}, -} +default_entry_data = {"broker": "mock-broker", "protocol": "5"} +default_entry_options = {"birth_message": {}} async def test_entry_diagnostics( @@ -145,6 +141,7 @@ async def test_entry_diagnostics( ( { mqtt.CONF_BROKER: "mock-broker", + mqtt.CONF_PROTOCOL: "5", CONF_PASSWORD: "hunter2", CONF_USERNAME: "my_user", }, diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index b786d3a4ca660..c31e95d6afb00 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -329,7 +329,7 @@ async def test_default_entity_and_device_name( entry = MockConfigEntry( domain=mqtt.DOMAIN, - data={mqtt.CONF_BROKER: "mock-broker"}, + data={mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_PROTOCOL: "5"}, version=mqtt.CONFIG_ENTRY_VERSION, minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) diff --git a/tests/components/mqtt/test_repairs.py b/tests/components/mqtt/test_repairs.py index bc7b9dd429459..b7da34c445019 100644 --- a/tests/components/mqtt/test_repairs.py +++ b/tests/components/mqtt/test_repairs.py @@ -1,18 +1,19 @@ """Test repairs for MQTT.""" -from collections.abc import Coroutine +from collections.abc import Coroutine, Generator from copy import deepcopy from typing import Any -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from homeassistant.components import mqtt from homeassistant.config_entries import ConfigSubentry, ConfigSubentryData -from homeassistant.const import SERVICE_RELOAD +from homeassistant.const import CONF_PORT, CONF_PROTOCOL, SERVICE_RELOAD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.setup import async_setup_component from homeassistant.util.yaml import parse_yaml from .common import MOCK_NOTIFY_SUBENTRY_DATA_MULTI, async_fire_mqtt_message @@ -27,6 +28,13 @@ from tests.typing import MqttMockHAClientGenerator +@pytest.fixture +def mock_try_connection() -> Generator[MagicMock]: + """Mock the try connection method.""" + with patch("homeassistant.components.mqtt.repairs.try_connection") as mock_try: + yield mock_try + + async def help_setup_yaml(hass: HomeAssistant, config: dict[str, str]) -> None: """Help to set up an exported MQTT device via YAML.""" with patch( @@ -177,3 +185,183 @@ async def test_subentry_reconfigure_export_settings( device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) assert device.config_entries_subentries[config_entry.entry_id] == {None} assert device is not None + + +@pytest.mark.parametrize( + ("mqtt_config_entry_data", "current_protocol"), + [ + ( + { + mqtt.CONF_BROKER: "mock-broker", + }, + "3.1.1", + ), + ( + { + mqtt.CONF_BROKER: "mock-broker", + CONF_PORT: 1883, + }, + "3.1.1", + ), + ( + { + mqtt.CONF_BROKER: "mock-broker", + CONF_PROTOCOL: "3.1.1", + CONF_PORT: 1883, + }, + "3.1.1", + ), + ( + { + mqtt.CONF_BROKER: "mock-broker", + CONF_PROTOCOL: "3.1", + CONF_PORT: 1883, + }, + "3.1", + ), + ], + ids=[ + "entry_without_protocol_without_port", + "entry_without_protocol_with_port", + "entry_with_protocol_3.1.1", + "entry_with_protocol_3.1", + ], +) +async def test_mqtt_protocol_successful_migration_to_v5( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + hass_client: ClientSessionGenerator, + mqtt_config_entry_data: dict[str, Any], + current_protocol: str, + mock_try_connection: MagicMock, +) -> None: + """Test the MQTT protocol migration repair flow is successful.""" + assert await async_setup_component(hass, "repairs", {}) + + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) + + await mqtt_mock_entry() + assert len(events) == 1 + assert events[0].data["issue_id"] == "protocol_5_migration" + + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(mqtt.DOMAIN, "protocol_5_migration") + assert issue is not None + assert issue.translation_key == "protocol_5_migration" + assert issue.translation_placeholders == { + "broker": "mock-broker", + "protocol": current_protocol, + } + + await async_process_repairs_platforms(hass) + client = await hass_client() + + data = await start_repair_fix_flow(client, mqtt.DOMAIN, "protocol_5_migration") + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + "broker": "mock-broker", + "protocol": current_protocol, + } + assert data["step_id"] == "confirm" + + mock_try_connection.side_effect = lambda x: True + data = await process_repair_fix_flow(client, flow_id) + assert data["type"] == "create_entry" + expected_entry_data: dict[str, Any] = mqtt_config_entry_data | {CONF_PROTOCOL: "5"} + mock_try_connection.assert_called_once_with(expected_entry_data | {CONF_PORT: 1883}) + entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + assert entry.data == expected_entry_data + await hass.async_block_till_done(wait_background_tasks=True) + + +@pytest.mark.parametrize( + ("mqtt_config_entry_data", "current_protocol"), + [ + ( + { + mqtt.CONF_BROKER: "mock-broker", + }, + "3.1.1", + ), + ( + { + mqtt.CONF_BROKER: "mock-broker", + CONF_PORT: 1883, + }, + "3.1.1", + ), + ( + { + mqtt.CONF_BROKER: "mock-broker", + CONF_PROTOCOL: "3.1.1", + CONF_PORT: 1883, + }, + "3.1.1", + ), + ( + { + mqtt.CONF_BROKER: "mock-broker", + CONF_PROTOCOL: "3.1", + CONF_PORT: 1883, + }, + "3.1", + ), + ], + ids=[ + "entry_without_protocol_without_port", + "entry_without_protocol_with_port", + "entry_with_protocol_3.1.1", + "entry_with_protocol_3.1", + ], +) +async def test_mqtt_protocol_failed_migration_to_v5( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + hass_client: ClientSessionGenerator, + current_protocol: str, + mock_try_connection: MagicMock, +) -> None: + """Test the MQTT protocol migration repair flow fails.""" + assert await async_setup_component(hass, "repairs", {}) + + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) + + await mqtt_mock_entry() + assert len(events) == 1 + assert events[0].data["issue_id"] == "protocol_5_migration" + + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(mqtt.DOMAIN, "protocol_5_migration") + assert issue is not None + assert issue.translation_key == "protocol_5_migration" + assert issue.translation_placeholders == { + "broker": "mock-broker", + "protocol": current_protocol, + } + + await async_process_repairs_platforms(hass) + client = await hass_client() + + data = await start_repair_fix_flow(client, mqtt.DOMAIN, "protocol_5_migration") + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + "broker": "mock-broker", + "protocol": current_protocol, + } + assert data["step_id"] == "confirm" + + mock_try_connection.side_effect = lambda x: False + data = await process_repair_fix_flow(client, flow_id) + assert data["type"] == "abort" + assert data["reason"] == "mqtt_broker_migration_to_v5_failed" + assert data["description_placeholders"] == { + "broker": "mock-broker", + "protocol": current_protocol, + "url_mqtt_broker_configuration": "https://www.home-assistant.io/integrations/mqtt/#broker-configuration", + } + + await hass.async_block_till_done(wait_background_tasks=True) diff --git a/tests/conftest.py b/tests/conftest.py index c28ea360c9ea3..313fccbb730e7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1140,7 +1140,10 @@ async def _mqtt_mock_entry( from homeassistant.components import mqtt # noqa: PLC0415 if mqtt_config_entry_data is None: - mqtt_config_entry_data = {mqtt.CONF_BROKER: "mock-broker"} + mqtt_config_entry_data = { + mqtt.CONF_BROKER: "mock-broker", + mqtt.CONF_PROTOCOL: "5", + } if mqtt_config_entry_options is None: mqtt_config_entry_options = {mqtt.CONF_BIRTH_MESSAGE: {}} From 3587f9613f71ce1516b84716d8b7557c13d3c3b0 Mon Sep 17 00:00:00 2001 From: Raj Laud <50647620+rajlaud@users.noreply.github.com> Date: Tue, 5 May 2026 07:57:19 -0400 Subject: [PATCH 02/11] Bump victron-ble-ha-parser to 0.7.0 (#169736) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- homeassistant/components/victron_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/victron_ble/fixtures.py | 12 ++++++++++++ tests/components/victron_ble/test_config_flow.py | 4 ++-- 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/victron_ble/manifest.json b/homeassistant/components/victron_ble/manifest.json index 3a5ea6222a203..c1969f5db3759 100644 --- a/homeassistant/components/victron_ble/manifest.json +++ b/homeassistant/components/victron_ble/manifest.json @@ -15,5 +15,5 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["victron-ble-ha-parser==0.6.3"] + "requirements": ["victron-ble-ha-parser==0.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 415afb743ac2f..1eeaa67e30cf6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3255,7 +3255,7 @@ venstarcolortouch==0.21 viaggiatreno_ha==0.2.4 # homeassistant.components.victron_ble -victron-ble-ha-parser==0.6.3 +victron-ble-ha-parser==0.7.0 # homeassistant.components.victron_gx victron-mqtt==2026.4.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d823cf74475b8..39a702b22ea7c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2767,7 +2767,7 @@ velbus-aio==2026.4.1 venstarcolortouch==0.21 # homeassistant.components.victron_ble -victron-ble-ha-parser==0.6.3 +victron-ble-ha-parser==0.7.0 # homeassistant.components.victron_gx victron-mqtt==2026.4.17 diff --git a/tests/components/victron_ble/fixtures.py b/tests/components/victron_ble/fixtures.py index cacbeda018890..28b59a0aa3100 100644 --- a/tests/components/victron_ble/fixtures.py +++ b/tests/components/victron_ble/fixtures.py @@ -112,6 +112,18 @@ source="local", ) +VICTRON_INVERTER_RS_SERVICE_INFO = BluetoothServiceInfo( + name="Inverter RS", + address="01:02:03:04:05:16", + rssi=-60, + manufacturer_data={ + 0x02E1: bytes.fromhex("1000a2a2061252dad26f0b8eb39162074d140df410"), + }, + service_data={}, + service_uuids=[], + source="local", +) + # SmartLithium (8-cell, 24V) VICTRON_SMART_LITHIUM_SERVICE_INFO = BluetoothServiceInfo( diff --git a/tests/components/victron_ble/test_config_flow.py b/tests/components/victron_ble/test_config_flow.py index 0e6b7145815ab..34851f9899c1e 100644 --- a/tests/components/victron_ble/test_config_flow.py +++ b/tests/components/victron_ble/test_config_flow.py @@ -14,7 +14,7 @@ from .fixtures import ( NOT_VICTRON_SERVICE_INFO, - VICTRON_INVERTER_SERVICE_INFO, + VICTRON_INVERTER_RS_SERVICE_INFO, VICTRON_TEST_WRONG_TOKEN, VICTRON_VEBUS_SERVICE_INFO, VICTRON_VEBUS_TOKEN, @@ -96,7 +96,7 @@ async def test_async_step_bluetooth_invalid_key_retry( ), ( SOURCE_BLUETOOTH, - VICTRON_INVERTER_SERVICE_INFO, + VICTRON_INVERTER_RS_SERVICE_INFO, "not_supported", ), ( From 8521a49986e51afdf7e75b66dccea1400332bd83 Mon Sep 17 00:00:00 2001 From: cengelen Date: Tue, 5 May 2026 14:11:50 +0200 Subject: [PATCH 03/11] Bump growatt server to 2.1.0 (#169495) Co-authored-by: Copilot --- .../components/growatt_server/coordinator.py | 8 +++- .../components/growatt_server/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../growatt_server/test_config_flow.py | 20 ++++++---- tests/components/growatt_server/test_init.py | 37 ++++++++++++++----- .../components/growatt_server/test_number.py | 8 +++- .../components/growatt_server/test_sensor.py | 8 +++- .../components/growatt_server/test_switch.py | 8 +++- 9 files changed, 67 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/growatt_server/coordinator.py b/homeassistant/components/growatt_server/coordinator.py index f978860b33eec..6cb9e94267d70 100644 --- a/homeassistant/components/growatt_server/coordinator.py +++ b/homeassistant/components/growatt_server/coordinator.py @@ -596,7 +596,9 @@ async def read_ac_charge_times(self) -> dict: if not self.data: await self.async_refresh() - return self.api.sph_read_ac_charge_times(settings_data=self.data) + return self.api.sph_read_ac_charge_times( + self.device_id, settings_data=self.data + ) async def read_ac_discharge_times(self) -> dict: """Read AC discharge time settings from SPH device cache.""" @@ -609,4 +611,6 @@ async def read_ac_discharge_times(self) -> dict: if not self.data: await self.async_refresh() - return self.api.sph_read_ac_discharge_times(settings_data=self.data) + return self.api.sph_read_ac_discharge_times( + self.device_id, settings_data=self.data + ) diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json index b00983d7f2b60..9fe6b4a4a5f6e 100644 --- a/homeassistant/components/growatt_server/manifest.json +++ b/homeassistant/components/growatt_server/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["growattServer"], "quality_scale": "silver", - "requirements": ["growattServer==1.9.0"] + "requirements": ["growattServer==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1eeaa67e30cf6..062c432976592 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1168,7 +1168,7 @@ greenwavereality==0.5.1 gridnet==5.0.1 # homeassistant.components.growatt_server -growattServer==1.9.0 +growattServer==2.1.0 # homeassistant.components.google_sheets gspread==5.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 39a702b22ea7c..2d926a476f975 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1041,7 +1041,7 @@ greenplanet-energy-api==0.1.10 gridnet==5.0.1 # homeassistant.components.growatt_server -growattServer==1.9.0 +growattServer==2.1.0 # homeassistant.components.google_sheets gspread==5.5.0 diff --git a/tests/components/growatt_server/test_config_flow.py b/tests/components/growatt_server/test_config_flow.py index d57a67e6f7e38..bfabb0662e2ca 100644 --- a/tests/components/growatt_server/test_config_flow.py +++ b/tests/components/growatt_server/test_config_flow.py @@ -371,8 +371,9 @@ async def test_token_auth_api_error( result["flow_id"], {"next_step_id": "token_auth"} ) - error = growattServer.GrowattV1ApiError("API error") - error.error_code = error_code + error = growattServer.GrowattV1ApiError( + message="API error", error_code=error_code, error_msg="API error" + ) mock_growatt_v1_api.plant_list.side_effect = error result = await hass.config_entries.flow.async_configure( @@ -882,9 +883,11 @@ async def test_reauth_token_success( def _make_no_privilege_error() -> growattServer.GrowattV1ApiError: - error = growattServer.GrowattV1ApiError("No privilege access") - error.error_code = V1_API_ERROR_NO_PRIVILEGE - return error + return growattServer.GrowattV1ApiError( + message="No privilege access", + error_code=growattServer.GrowattV1ApiErrorCode.NO_PRIVILEGE, + error_msg="No privilege access", + ) @pytest.mark.parametrize( @@ -942,8 +945,11 @@ async def test_reauth_token_non_auth_api_error( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - error = growattServer.GrowattV1ApiError("Rate limit exceeded") - error.error_code = V1_API_ERROR_RATE_LIMITED + error = growattServer.GrowattV1ApiError( + message="Rate limit exceeded", + error_code=growattServer.GrowattV1ApiErrorCode.RATE_LIMITED, + error_msg="Rate limit exceeded", + ) mock_growatt_v1_api.plant_list.side_effect = error result = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_USER_INPUT_TOKEN diff --git a/tests/components/growatt_server/test_init.py b/tests/components/growatt_server/test_init.py index 25b38f6389d22..37c27ecfdca0b 100644 --- a/tests/components/growatt_server/test_init.py +++ b/tests/components/growatt_server/test_init.py @@ -19,8 +19,7 @@ DEFAULT_PLANT_ID, DOMAIN, LOGIN_INVALID_AUTH_CODE, - V1_API_ERROR_NO_PRIVILEGE, - V1_API_ERROR_RATE_LIMITED, + V1_API_ERROR_WRONG_DOMAIN, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -67,7 +66,14 @@ async def test_device_info( @pytest.mark.parametrize( ("exception", "expected_state"), [ - (growattServer.GrowattV1ApiError("API Error"), ConfigEntryState.SETUP_ERROR), + ( + growattServer.GrowattV1ApiError( + message="API Error", + error_code=V1_API_ERROR_WRONG_DOMAIN, + error_msg="Invalid JSON", + ), + ConfigEntryState.SETUP_ERROR, + ), ( json.decoder.JSONDecodeError("Invalid JSON", "", 0), ConfigEntryState.SETUP_ERROR, @@ -102,7 +108,9 @@ async def test_coordinator_update_failed( # Cause coordinator update to fail mock_growatt_v1_api.min_detail.side_effect = growattServer.GrowattV1ApiError( - "Connection timeout" + message="Rate limited", + error_code=growattServer.GrowattV1ApiErrorCode.RATE_LIMITED, + error_msg="Too many requests", ) # Trigger coordinator refresh @@ -148,8 +156,11 @@ async def test_coordinator_total_non_auth_api_error( """Test total coordinator handles non-auth V1 API errors as UpdateFailed.""" assert mock_config_entry.state is ConfigEntryState.LOADED - error = growattServer.GrowattV1ApiError("Rate limited") - error.error_code = V1_API_ERROR_RATE_LIMITED + error = growattServer.GrowattV1ApiError( + message="Rate limited", + error_code=growattServer.GrowattV1ApiErrorCode.RATE_LIMITED, + error_msg="Too many requests", + ) mock_growatt_v1_api.plant_energy_overview.side_effect = error freezer.tick(timedelta(minutes=5)) @@ -168,8 +179,11 @@ async def test_setup_auth_failed_on_permission_denied( mock_config_entry: MockConfigEntry, ) -> None: """Test that error 10011 (no privilege) from device_list triggers reauth during setup.""" - error = growattServer.GrowattV1ApiError("Permission denied") - error.error_code = V1_API_ERROR_NO_PRIVILEGE + error = growattServer.GrowattV1ApiError( + message="Permission denied", + error_code=growattServer.GrowattV1ApiErrorCode.NO_PRIVILEGE, + error_msg="dummy error", + ) mock_growatt_v1_api.device_list.side_effect = error await setup_integration(hass, mock_config_entry) @@ -194,8 +208,11 @@ async def test_coordinator_auth_failed_triggers_reauth( await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED - error = growattServer.GrowattV1ApiError("Permission denied") - error.error_code = V1_API_ERROR_NO_PRIVILEGE + error = growattServer.GrowattV1ApiError( + message="Permission denied", + error_code=growattServer.GrowattV1ApiErrorCode.NO_PRIVILEGE, + error_msg="dummy error", + ) mock_growatt_v1_api.min_detail.side_effect = error freezer.tick(timedelta(minutes=5)) diff --git a/tests/components/growatt_server/test_number.py b/tests/components/growatt_server/test_number.py index 12f0bcd7cb70d..6c4e095e7ac61 100644 --- a/tests/components/growatt_server/test_number.py +++ b/tests/components/growatt_server/test_number.py @@ -4,7 +4,7 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory -from growattServer import GrowattV1ApiError +from growattServer import GrowattV1ApiError, GrowattV1ApiErrorCode import pytest from syrupy.assertion import SnapshotAssertion @@ -52,7 +52,11 @@ async def test_set_number_value_api_error( ) -> None: """Test handling API error when setting number value.""" # Mock API to raise error - mock_growatt_v1_api.min_write_parameter.side_effect = GrowattV1ApiError("API Error") + mock_growatt_v1_api.min_write_parameter.side_effect = GrowattV1ApiError( + message="API Error", + error_code=GrowattV1ApiErrorCode.NO_PRIVILEGE, + error_msg="API Error", + ) with pytest.raises(HomeAssistantError) as excinfo: await hass.services.async_call( diff --git a/tests/components/growatt_server/test_sensor.py b/tests/components/growatt_server/test_sensor.py index e1a5a51420739..e64a2a864e2c8 100644 --- a/tests/components/growatt_server/test_sensor.py +++ b/tests/components/growatt_server/test_sensor.py @@ -56,7 +56,9 @@ async def test_sph_sensor_unavailable_on_coordinator_error( assert state.state != STATE_UNAVAILABLE mock_growatt_v1_api.sph_detail.side_effect = growattServer.GrowattV1ApiError( - "Connection timeout" + message="Rate limited", + error_code=growattServer.GrowattV1ApiErrorCode.RATE_LIMITED, + error_msg="Too many requests", ) freezer.tick(timedelta(minutes=5)) @@ -175,7 +177,9 @@ async def test_sensor_unavailable_on_coordinator_error( # Cause coordinator update to fail mock_growatt_v1_api.min_detail.side_effect = growattServer.GrowattV1ApiError( - "Connection timeout" + message="Rate limited", + error_code=growattServer.GrowattV1ApiErrorCode.RATE_LIMITED, + error_msg="Too many requests", ) # Trigger coordinator refresh diff --git a/tests/components/growatt_server/test_switch.py b/tests/components/growatt_server/test_switch.py index ef192271a2130..60d32ea0dbcbb 100644 --- a/tests/components/growatt_server/test_switch.py +++ b/tests/components/growatt_server/test_switch.py @@ -4,7 +4,7 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory -from growattServer import GrowattV1ApiError +from growattServer import GrowattV1ApiError, GrowattV1ApiErrorCode import pytest from syrupy.assertion import SnapshotAssertion @@ -89,7 +89,11 @@ async def test_switch_service_call_api_error( ) -> None: """Test handling API error when calling switch services.""" # Mock API to raise error - mock_growatt_v1_api.min_write_parameter.side_effect = GrowattV1ApiError("API Error") + mock_growatt_v1_api.min_write_parameter.side_effect = GrowattV1ApiError( + message="API Error", + error_code=GrowattV1ApiErrorCode.NO_PRIVILEGE, + error_msg="API Error", + ) with pytest.raises(HomeAssistantError) as excinfo: await hass.services.async_call( From e144804d28f552d4ab9137724975383682c54d36 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 5 May 2026 15:03:37 +0200 Subject: [PATCH 04/11] Fix async_unload teardown race in scripts (#169562) --- .../components/automation/__init__.py | 5 +- .../components/intent_script/__init__.py | 5 +- homeassistant/components/script/__init__.py | 12 +- .../components/wake_on_lan/switch.py | 4 +- .../components/websocket_api/commands.py | 2 +- homeassistant/helpers/script.py | 43 ++++--- tests/helpers/test_script.py | 109 +++++++++++++----- 7 files changed, 121 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 85a3edcc59d13..13df2e67d2cef 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -899,12 +899,13 @@ def started_action() -> None: async def async_will_remove_from_hass(self) -> None: """Remove listeners when removing automation from Home Assistant.""" await super().async_will_remove_from_hass() - await self._async_disable() if self.registry_entry and self.registry_entry.entity_id != self.entity_id: # Entity ID change, do not unload the script or conditions as they will # be reused. + await self._async_disable() return - self.action_script.async_unload() + await self._async_disable(stop_actions=False) + await self.action_script.async_unload() if self._condition is not None: self._condition.async_unload() diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index ad5dc42d3a559..5c5cb438b3307 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -77,10 +77,9 @@ async def async_reload(hass: HomeAssistant, service_call: ServiceCall) -> None: existing_intents = hass.data[DOMAIN] for intent_type, conf in existing_intents.items(): - if isinstance(conf.get(CONF_ACTION), script.Script): - await conf[CONF_ACTION].async_stop() - conf[CONF_ACTION].async_unload() intent.async_remove(hass, intent_type) + if isinstance(conf.get(CONF_ACTION), script.Script): + await conf[CONF_ACTION].async_unload() if not new_config or DOMAIN not in new_config: hass.data[DOMAIN] = {} diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 035c8b13c8e34..730a8e1621296 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -766,14 +766,14 @@ async def async_added_to_hass(self) -> None: async def async_will_remove_from_hass(self) -> None: """Stop script and remove service when it will be removed from HA.""" - await self.script.async_stop() - if not self.registry_entry or self.registry_entry.entity_id == self.entity_id: - # Entity ID not changed, unload the script as it will not be reused. - self.script.async_unload() - - # remove service self.hass.services.async_remove(DOMAIN, self._attr_unique_id) + if self.registry_entry and self.registry_entry.entity_id != self.entity_id: + # Entity ID change, do not unload the script as it will be reused. + await self.script.async_stop() + return + await self.script.async_unload() + @websocket_api.websocket_command({"type": "script/config", "entity_id": str}) def websocket_config( diff --git a/homeassistant/components/wake_on_lan/switch.py b/homeassistant/components/wake_on_lan/switch.py index d7046d913b893..da639769fc5c1 100644 --- a/homeassistant/components/wake_on_lan/switch.py +++ b/homeassistant/components/wake_on_lan/switch.py @@ -127,11 +127,11 @@ async def async_will_remove_from_hass(self) -> None: """Clean up script when removing from Home Assistant.""" if self._off_script is None: return - await self._off_script.async_stop() if self.registry_entry and self.registry_entry.entity_id != self.entity_id: # Entity ID change, do not unload the script as it will be reused. + await self._off_script.async_stop() return - self._off_script.async_unload() + await self._off_script.async_unload() def turn_off(self, **kwargs: Any) -> None: """Turn the device off if an off action is present.""" diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index f8f40215524d6..ce92ee2ac5746 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -1075,7 +1075,7 @@ async def handle_execute_script( ) return finally: - script_obj.async_unload() + await script_obj.async_unload() connection.send_result( msg["id"], { diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index a85aa94c12b99..015771082cf18 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -1519,7 +1519,7 @@ def __del__(self) -> None: if self._unloaded: return try: - self.async_unload() + self._async_unload() except Exception: _LOGGER.exception("Error while unloading script") @@ -1787,11 +1787,16 @@ async def async_run( started_action: Callable[..., Any] | None = None, ) -> ScriptRunResult | None: """Run script.""" - # Prevent running an unloaded script if self._unloaded: raise RuntimeError( f"Cannot run script '{self.name}' after it has been unloaded" ) + if DATA_NEW_SCRIPT_RUNS_NOT_ALLOWED in self._hass.data: + self._log("Home Assistant is shutting down, starting script blocked") + return None + # The fences above rely on there being no await between these checks + # and the _runs.append below, so that setting either flag is + # sufficient to block new runs from being added. if context is None: self._log( @@ -1799,11 +1804,6 @@ async def async_run( ) context = Context() - # Prevent spawning new script runs when Home Assistant is shutting down - if DATA_NEW_SCRIPT_RUNS_NOT_ALLOWED in self._hass.data: - self._log("Home Assistant is shutting down, starting script blocked") - return None - # Prevent spawning new script runs if not allowed by script mode if self.is_running: if self.script_mode == SCRIPT_MODE_SINGLE: @@ -1913,7 +1913,20 @@ async def async_stop( return await asyncio.shield(create_eager_task(self._async_stop(aws, update_state))) - def async_unload(self) -> None: + async def async_unload(self) -> None: + """Unload the script, stopping any in-flight runs first. + + Blocks new runs immediately, stops any in-flight runs, then cleans + up all resources. + """ + if self._unloaded: + return + # Set the flag before stopping so async_run rejects new runs. + self._unloaded = True + await self.async_stop() + self._async_unload() + + def _async_unload(self) -> None: """Unload the script, cleaning up all resources. Unloads cached conditions, and recursively unloads sub-scripts. @@ -1935,31 +1948,31 @@ def async_unload(self) -> None: self._condition_cache.clear() for sub_script in self._repeat_script.values(): - sub_script.async_unload() + sub_script._async_unload() # noqa: SLF001 self._repeat_script.clear() # Conditions in _choose_data and _if_data are the same objects as in # _condition_cache, so they're already unloaded above. Only unload scripts. for choose_data in self._choose_data.values(): for _conditions, sub_script in choose_data["choices"]: - sub_script.async_unload() + sub_script._async_unload() # noqa: SLF001 if choose_data["default"] is not None: - choose_data["default"].async_unload() + choose_data["default"]._async_unload() # noqa: SLF001 self._choose_data.clear() for if_data in self._if_data.values(): - if_data["if_then"].async_unload() + if_data["if_then"]._async_unload() # noqa: SLF001 if if_data["if_else"] is not None: - if_data["if_else"].async_unload() + if_data["if_else"]._async_unload() # noqa: SLF001 self._if_data.clear() for scripts in self._parallel_scripts.values(): for sub_script in scripts: - sub_script.async_unload() + sub_script._async_unload() # noqa: SLF001 self._parallel_scripts.clear() for sub_script in self._sequence_scripts.values(): - sub_script.async_unload() + sub_script._async_unload() # noqa: SLF001 self._sequence_scripts.clear() async def _async_get_condition(self, config: ConfigType) -> ConditionChecker: diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index bed50747d1170..267eecafe6491 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -6956,7 +6956,7 @@ async def test_async_unload_clears_condition_cache(hass: HomeAssistant) -> None: assert len(script_obj._condition_cache) == 1 cached_cond = next(iter(script_obj._condition_cache.values())) - script_obj.async_unload() + await script_obj.async_unload() assert len(script_obj._condition_cache) == 0 cached_cond.async_unload.assert_called_once() @@ -6982,8 +6982,8 @@ async def test_async_unload_clears_repeat_scripts(hass: HomeAssistant) -> None: assert len(script_obj._repeat_script) == 1 sub_script = next(iter(script_obj._repeat_script.values())) - with mock.patch.object(sub_script, "async_unload") as unload_mock: - script_obj.async_unload() + with mock.patch.object(sub_script, "_async_unload") as unload_mock: + await script_obj.async_unload() assert len(script_obj._repeat_script) == 0 unload_mock.assert_called_once() @@ -7015,10 +7015,10 @@ async def test_async_unload_clears_choose_data(hass: HomeAssistant) -> None: default_script = choose_data["default"] with ( - mock.patch.object(choice_script, "async_unload") as choice_unload, - mock.patch.object(default_script, "async_unload") as default_unload, + mock.patch.object(choice_script, "_async_unload") as choice_unload, + mock.patch.object(default_script, "_async_unload") as default_unload, ): - script_obj.async_unload() + await script_obj.async_unload() assert len(script_obj._choose_data) == 0 choice_unload.assert_called_once() @@ -7047,10 +7047,10 @@ async def test_async_unload_clears_if_data(hass: HomeAssistant) -> None: else_script = if_data["if_else"] with ( - mock.patch.object(then_script, "async_unload") as then_unload, - mock.patch.object(else_script, "async_unload") as else_unload, + mock.patch.object(then_script, "_async_unload") as then_unload, + mock.patch.object(else_script, "_async_unload") as else_unload, ): - script_obj.async_unload() + await script_obj.async_unload() assert len(script_obj._if_data) == 0 then_unload.assert_called_once() @@ -7079,10 +7079,10 @@ async def test_async_unload_clears_parallel_scripts(hass: HomeAssistant) -> None assert len(parallel_scripts) == 2 with ( - mock.patch.object(parallel_scripts[0], "async_unload") as unload_0, - mock.patch.object(parallel_scripts[1], "async_unload") as unload_1, + mock.patch.object(parallel_scripts[0], "_async_unload") as unload_0, + mock.patch.object(parallel_scripts[1], "_async_unload") as unload_1, ): - script_obj.async_unload() + await script_obj.async_unload() assert len(script_obj._parallel_scripts) == 0 unload_0.assert_called_once() @@ -7090,11 +7090,11 @@ async def test_async_unload_clears_parallel_scripts(hass: HomeAssistant) -> None async def test_script_del_calls_async_unload(hass: HomeAssistant) -> None: - """Test that __del__ calls async_unload if not already called.""" + """Test that __del__ calls _async_unload if not already called.""" sequence = cv.SCRIPT_SCHEMA([{"event": "test_event"}]) script_obj = script.Script(hass, sequence, "Test Name", "test_domain") - unload_mock = mock.Mock(wraps=script_obj.async_unload) - script_obj.async_unload = unload_mock + unload_mock = mock.Mock(wraps=script_obj._async_unload) + script_obj._async_unload = unload_mock # Pylint says we should `del script_obj`. However, that's not guaranteed # to immediately call __del__. @@ -7103,14 +7103,14 @@ async def test_script_del_calls_async_unload(hass: HomeAssistant) -> None: async def test_script_del_skips_if_already_unloaded(hass: HomeAssistant) -> None: - """Test that __del__ does not call async_unload if already called.""" + """Test that __del__ does not call _async_unload if already called.""" sequence = cv.SCRIPT_SCHEMA([{"event": "test_event"}]) script_obj = script.Script(hass, sequence, "Test Name", "test_domain") - unload_mock = mock.Mock(wraps=script_obj.async_unload) - script_obj.async_unload = unload_mock + unload_mock = mock.Mock(wraps=script_obj._async_unload) + script_obj._async_unload = unload_mock # First call sets the flag - script_obj.async_unload() + await script_obj.async_unload() unload_mock.assert_called_once() unload_mock.reset_mock() @@ -7121,8 +7121,8 @@ async def test_script_del_skips_if_already_unloaded(hass: HomeAssistant) -> None unload_mock.assert_not_called() -async def test_async_unload_raises_if_running(hass: HomeAssistant) -> None: - """Test that async_unload raises RuntimeError if the script is running.""" +async def test_async_unload_stops_running_script(hass: HomeAssistant) -> None: + """Test that async_unload stops in-flight runs and unloads the script.""" sequence = cv.SCRIPT_SCHEMA( [ {"wait_template": "{{ false }}"}, @@ -7135,14 +7135,10 @@ async def test_async_unload_raises_if_running(hass: HomeAssistant) -> None: assert script_obj.is_running - with pytest.raises(RuntimeError, match="Cannot unload script"): - script_obj.async_unload() + await script_obj.async_unload() - await script_obj.async_stop() assert not script_obj.is_running - - # Should succeed now - script_obj.async_unload() + assert script_obj._unloaded async def test_async_unload_removes_from_data_scripts(hass: HomeAssistant) -> None: @@ -7153,7 +7149,7 @@ async def test_async_unload_removes_from_data_scripts(hass: HomeAssistant) -> No all_scripts = hass.data[script.DATA_SCRIPTS] assert any(s["instance"] is script_obj for s in all_scripts.values()) - script_obj.async_unload() + await script_obj.async_unload() assert not any(s["instance"] is script_obj for s in all_scripts.values()) @@ -7171,7 +7167,7 @@ async def test_async_unload_non_top_level_does_not_touch_data_scripts( count_before = len(all_scripts) # Should not raise and should not modify DATA_SCRIPTS - script_obj.async_unload() + await script_obj.async_unload() assert len(all_scripts) == count_before @@ -7181,9 +7177,62 @@ async def test_async_run_raises_if_unloaded(hass: HomeAssistant) -> None: sequence = cv.SCRIPT_SCHEMA([{"event": "test_event"}]) script_obj = script.Script(hass, sequence, "Test Name", "test_domain") - script_obj.async_unload() + await script_obj.async_unload() + + with pytest.raises( + RuntimeError, match="Cannot run script.*after it has been unloaded" + ): + await script_obj.async_run(context=Context()) + + +async def test_async_unload_blocks_new_runs_during_stop( + hass: HomeAssistant, +) -> None: + """Test that new runs are rejected once unload has started. + + A run started after unload begins must be rejected immediately; + otherwise it would survive the stop and prevent cleanup. + """ + sequence = cv.SCRIPT_SCHEMA([{"wait_template": "{{ false }}"}]) + # Parallel mode so the script-mode check would not reject the second run; + # the only thing that should reject it is the _unloaded fence. + script_obj = script.Script( + hass, sequence, "Test Name", "test_domain", script_mode="parallel", max_runs=2 + ) + script_id = id(script_obj) + + assert script_id in hass.data[script.DATA_SCRIPTS] + + hass.async_create_task(script_obj.async_run(context=Context())) + await asyncio.sleep(0) + assert script_obj.is_running + + # Gate async_stop so the test can act while unload is parked mid-stop, + # i.e. after _unloaded=True but before runs are stopped. + stop_started = asyncio.Event() + stop_release = asyncio.Event() + original_async_stop = script_obj.async_stop + + async def gated_async_stop(*args: Any, **kwargs: Any) -> None: + stop_started.set() + await stop_release.wait() + await original_async_stop(*args, **kwargs) + + script_obj.async_stop = gated_async_stop + + unload_task = hass.async_create_task(script_obj.async_unload()) + await stop_started.wait() + + assert script_obj._unloaded + assert script_obj.is_running with pytest.raises( RuntimeError, match="Cannot run script.*after it has been unloaded" ): await script_obj.async_run(context=Context()) + + stop_release.set() + await unload_task + + assert not script_obj.is_running + assert script_id not in hass.data[script.DATA_SCRIPTS] From b1e8dc2ebb8992d354ac34a2d831f8ccc8ca74b3 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 5 May 2026 15:42:08 +0200 Subject: [PATCH 05/11] Remove show_advanced_options in Ecovacs and always show all options (#169831) --- .../components/ecovacs/config_flow.py | 4 - tests/components/ecovacs/test_config_flow.py | 73 ++++--------------- 2 files changed, 13 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/ecovacs/config_flow.py b/homeassistant/components/ecovacs/config_flow.py index d1ff235ac8b90..ac0043557dae4 100644 --- a/homeassistant/components/ecovacs/config_flow.py +++ b/homeassistant/components/ecovacs/config_flow.py @@ -137,10 +137,6 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - - if not self.show_advanced_options: - return await self.async_step_auth() - if user_input: self._mode = user_input[CONF_MODE] return await self.async_step_auth() diff --git a/tests/components/ecovacs/test_config_flow.py b/tests/components/ecovacs/test_config_flow.py index 3a0cb188146bf..bdfdebd7e6beb 100644 --- a/tests/components/ecovacs/test_config_flow.py +++ b/tests/components/ecovacs/test_config_flow.py @@ -1,6 +1,6 @@ """Test Ecovacs config flow.""" -from collections.abc import Awaitable, Callable +from collections.abc import Callable from dataclasses import dataclass, field import ssl from typing import Any @@ -50,26 +50,6 @@ async def _test_user_flow( context={"source": SOURCE_USER}, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "auth" - assert not result["errors"] - - return await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=user_input.auth, - ) - - -async def _test_user_flow_show_advanced_options( - hass: HomeAssistant, - user_input: _TestFnUserInput, -) -> dict[str, Any]: - """Test config flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER, "show_advanced_options": True}, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] @@ -90,37 +70,29 @@ async def _test_user_flow_show_advanced_options( @pytest.mark.parametrize( - ("test_fn", "test_fn_user_input", "entry_data"), + ("test_fn_user_input", "entry_data"), [ ( - _test_user_flow_show_advanced_options, _TestFnUserInput(VALID_ENTRY_DATA_CLOUD), VALID_ENTRY_DATA_CLOUD, ), ( - _test_user_flow_show_advanced_options, _TestFnUserInput(VALID_ENTRY_DATA_SELF_HOSTED, _USER_STEP_SELF_HOSTED), VALID_ENTRY_DATA_SELF_HOSTED, ), - ( - _test_user_flow, - _TestFnUserInput(VALID_ENTRY_DATA_CLOUD), - VALID_ENTRY_DATA_CLOUD, - ), ], - ids=["advanced_cloud", "advanced_self_hosted", "cloud"], + ids=["cloud", "self_hosted"], ) async def test_user_flow( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_authenticator_authenticate: AsyncMock, mock_mqtt_client: Mock, - test_fn: Callable[[HomeAssistant, _TestFnUserInput], Awaitable[dict[str, Any]]], test_fn_user_input: _TestFnUserInput, entry_data: dict[str, Any], ) -> None: """Test the user config flow.""" - result = await test_fn(hass, test_fn_user_input) + result = await _test_user_flow(hass, test_fn_user_input) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == entry_data[CONF_USERNAME] assert result["data"] == entry_data @@ -156,25 +128,18 @@ def _cannot_connect_error(user_input: dict[str, Any]) -> str: ids=["cannot_connect", "invalid_auth", "unknown"], ) @pytest.mark.parametrize( - ("test_fn", "test_fn_user_input", "entry_data"), + ("test_fn_user_input", "entry_data"), [ ( - _test_user_flow_show_advanced_options, _TestFnUserInput(VALID_ENTRY_DATA_CLOUD), VALID_ENTRY_DATA_CLOUD, ), ( - _test_user_flow_show_advanced_options, _TestFnUserInput(VALID_ENTRY_DATA_SELF_HOSTED, _USER_STEP_SELF_HOSTED), VALID_ENTRY_DATA_SELF_HOSTED_WITH_VALIDATE_CERT, ), - ( - _test_user_flow, - _TestFnUserInput(VALID_ENTRY_DATA_CLOUD), - VALID_ENTRY_DATA_CLOUD, - ), ], - ids=["advanced_cloud", "advanced_self_hosted", "cloud"], + ids=["cloud", "self_hosted"], ) async def test_user_flow_raise_error( hass: HomeAssistant, @@ -185,7 +150,6 @@ async def test_user_flow_raise_error( reason_rest: str, side_effect_mqtt: Exception, errors_mqtt: Callable[[dict[str, Any]], str], - test_fn: Callable[[HomeAssistant, _TestFnUserInput], Awaitable[dict[str, Any]]], test_fn_user_input: _TestFnUserInput, entry_data: dict[str, Any], ) -> None: @@ -194,7 +158,7 @@ async def test_user_flow_raise_error( # Authenticator raises error mock_authenticator_authenticate.side_effect = side_effect_rest - result = await test_fn(hass, test_fn_user_input) + result = await _test_user_flow(hass, test_fn_user_input) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "auth" assert result["errors"] == {"base": reason_rest} @@ -240,7 +204,7 @@ async def test_user_flow_self_hosted_error( ) -> None: """Test handling selfhosted errors and custom ssl context.""" - result = await _test_user_flow_show_advanced_options( + result = await _test_user_flow( hass, _TestFnUserInput( VALID_ENTRY_DATA_SELF_HOSTED @@ -289,32 +253,21 @@ async def test_user_flow_self_hosted_error( @pytest.mark.parametrize( - ("test_fn", "test_fn_user_input"), + ("test_fn_user_input"), [ - ( - _test_user_flow_show_advanced_options, - _TestFnUserInput(VALID_ENTRY_DATA_CLOUD), - ), - ( - _test_user_flow_show_advanced_options, - _TestFnUserInput(VALID_ENTRY_DATA_SELF_HOSTED, _USER_STEP_SELF_HOSTED), - ), - ( - _test_user_flow, - _TestFnUserInput(VALID_ENTRY_DATA_CLOUD), - ), + _TestFnUserInput(VALID_ENTRY_DATA_CLOUD), + _TestFnUserInput(VALID_ENTRY_DATA_SELF_HOSTED, _USER_STEP_SELF_HOSTED), ], - ids=["advanced_cloud", "advanced_self_hosted", "cloud"], + ids=["cloud", "self_hosted"], ) async def test_already_exists( hass: HomeAssistant, - test_fn: Callable[[HomeAssistant, _TestFnUserInput], Awaitable[dict[str, Any]]], test_fn_user_input: _TestFnUserInput, ) -> None: """Test we don't allow duplicated config entries.""" MockConfigEntry(domain=DOMAIN, data=test_fn_user_input.auth).add_to_hass(hass) - result = await test_fn( + result = await _test_user_flow( hass, test_fn_user_input, ) From 0ec5d6b273b08cf6fb1cd9340fc8f1ab8286e28d Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Tue, 5 May 2026 15:48:43 +0200 Subject: [PATCH 06/11] Add API version to Duco diagnostics for support triage (#169802) --- homeassistant/components/duco/diagnostics.py | 14 +++++ tests/components/duco/conftest.py | 23 ++++++++ .../duco/snapshots/test_diagnostics.ambr | 8 ++- tests/components/duco/test_diagnostics.py | 54 ++++++++++++++++++- 4 files changed, 95 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/duco/diagnostics.py b/homeassistant/components/duco/diagnostics.py index 777fda6c70e3e..1cf8bc66b059d 100644 --- a/homeassistant/components/duco/diagnostics.py +++ b/homeassistant/components/duco/diagnostics.py @@ -13,6 +13,9 @@ from .const import DOMAIN from .coordinator import DucoConfigEntry +# MAC addresses and serial numbers are redacted because a Duco installer or +# manufacturer could cross-reference them against an installation registry to +# identify the physical location of the device. TO_REDACT = { CONF_HOST, "mac", @@ -31,9 +34,15 @@ async def async_get_config_entry_diagnostics( coordinator = entry.runtime_data board = asdict(coordinator.board_info) + # `time` is a Unix epoch timestamp of the last board info fetch; not useful for support triage. board.pop("time") + if board["public_api_version"] is None: + board.pop("public_api_version") + if board["software_version"] is None: + board.pop("software_version") try: + api_info_obj = await coordinator.client.async_get_api_info() lan_info = await coordinator.client.async_get_lan_info() duco_diags = await coordinator.client.async_get_diagnostics() write_remaining = await coordinator.client.async_get_write_req_remaining() @@ -43,10 +52,15 @@ async def async_get_config_entry_diagnostics( translation_key="connection_error", ) from err + api_info: dict[str, Any] = {"public_api_version": api_info_obj.public_api_version} + if api_info_obj.reported_api_version is not None: + api_info["reported_api_version"] = api_info_obj.reported_api_version + return async_redact_data( { "entry_data": entry.data, "board_info": board, + "api_info": api_info, "lan_info": asdict(lan_info), "nodes": { str(node_id): asdict(node) diff --git a/tests/components/duco/conftest.py b/tests/components/duco/conftest.py index 3487397a53fd2..c525acfffa5f0 100644 --- a/tests/components/duco/conftest.py +++ b/tests/components/duco/conftest.py @@ -4,6 +4,8 @@ from unittest.mock import AsyncMock, patch from duco.models import ( + ApiEndpointInfo, + ApiInfo, BoardInfo, DiagComponent, DiagStatus, @@ -49,6 +51,25 @@ def mock_board_info() -> BoardInfo: serial_duco_box="GHI789", serial_duco_comm="JKL012", time=1700000000, + public_api_version="2.5", + software_version="1.2.3", + ) + + +@pytest.fixture +def mock_api_info() -> ApiInfo: + """Return mock API info.""" + return ApiInfo( + api_version="2.5", + reported_api_version="2.5.1", + endpoints=[ + ApiEndpointInfo( + url="/info", + query_parameters=["module", "submodule"], + methods=["GET"], + modules=["General", "Diag"], + ) + ], ) @@ -180,6 +201,7 @@ def mock_nodes() -> list[Node]: @pytest.fixture def mock_duco_client( + mock_api_info: ApiInfo, mock_board_info: BoardInfo, mock_lan_info: LanInfo, mock_nodes: list[Node], @@ -202,6 +224,7 @@ def mock_duco_client( ), ): client = mock_class.return_value + client.async_get_api_info.return_value = mock_api_info client.async_get_board_info.return_value = mock_board_info client.async_get_lan_info.return_value = mock_lan_info client.async_get_nodes.return_value = mock_nodes diff --git a/tests/components/duco/snapshots/test_diagnostics.ambr b/tests/components/duco/snapshots/test_diagnostics.ambr index 76b108b65e3b3..7398883b1625b 100644 --- a/tests/components/duco/snapshots/test_diagnostics.ambr +++ b/tests/components/duco/snapshots/test_diagnostics.ambr @@ -1,15 +1,19 @@ # serializer version: 1 # name: test_diagnostics dict({ + 'api_info': dict({ + 'public_api_version': '2.5', + 'reported_api_version': '2.5.1', + }), 'board_info': dict({ 'box_name': 'SILENT_CONNECT', 'box_sub_type_name': 'Eu', - 'public_api_version': None, + 'public_api_version': '2.5', 'serial_board_box': '**REDACTED**', 'serial_board_comm': '**REDACTED**', 'serial_duco_box': '**REDACTED**', 'serial_duco_comm': '**REDACTED**', - 'software_version': None, + 'software_version': '1.2.3', }), 'duco_diagnostics': list([ dict({ diff --git a/tests/components/duco/test_diagnostics.py b/tests/components/duco/test_diagnostics.py index 3eae0e689e171..87289b1d398cd 100644 --- a/tests/components/duco/test_diagnostics.py +++ b/tests/components/duco/test_diagnostics.py @@ -1,9 +1,11 @@ """Tests for the Duco diagnostics.""" +from dataclasses import replace from http import HTTPStatus from unittest.mock import AsyncMock from duco.exceptions import DucoConnectionError +from duco.models import ApiInfo import pytest from syrupy.assertion import SnapshotAssertion @@ -24,7 +26,7 @@ async def test_diagnostics( mock_duco_client: AsyncMock, snapshot: SnapshotAssertion, ) -> None: - """Test diagnostics.""" + """Test that the full diagnostics payload matches the snapshot.""" assert ( await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) == snapshot @@ -34,7 +36,12 @@ async def test_diagnostics( @pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( "failing_method", - ["async_get_lan_info", "async_get_diagnostics", "async_get_write_req_remaining"], + [ + "async_get_api_info", + "async_get_lan_info", + "async_get_diagnostics", + "async_get_write_req_remaining", + ], ) async def test_diagnostics_connection_error( hass: HomeAssistant, @@ -54,3 +61,46 @@ async def test_diagnostics_connection_error( f"/api/diagnostics/config_entry/{mock_config_entry.entry_id}" ) assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR + + +async def test_diagnostics_without_optional_board_metadata( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, +) -> None: + """Test that None board fields are omitted from the diagnostics payload.""" + # BoardInfo is a frozen dataclass, so the mock must be updated before + # integration setup — the coordinator stores board_info during async_setup. + mock_duco_client.async_get_board_info.return_value = replace( + mock_duco_client.async_get_board_info.return_value, + public_api_version=None, + software_version=None, + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert "public_api_version" not in diagnostics["board_info"] + assert "software_version" not in diagnostics["board_info"] + + +@pytest.mark.usefixtures("init_integration") +async def test_diagnostics_without_optional_api_metadata( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, +) -> None: + """Test diagnostics when optional API metadata is absent.""" + mock_duco_client.async_get_api_info.return_value = ApiInfo(api_version="2.5") + + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert diagnostics["api_info"] == {"public_api_version": "2.5"} From 70c2a323ce08be0560b16015ba693c77e38a351d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 May 2026 16:17:49 +0200 Subject: [PATCH 07/11] Add Zunzunbee Zigbee brand (#169838) --- homeassistant/brands/zunzunbee.json | 5 +++++ homeassistant/generated/integrations.json | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 homeassistant/brands/zunzunbee.json diff --git a/homeassistant/brands/zunzunbee.json b/homeassistant/brands/zunzunbee.json new file mode 100644 index 0000000000000..d1c67a9cfc9a2 --- /dev/null +++ b/homeassistant/brands/zunzunbee.json @@ -0,0 +1,5 @@ +{ + "domain": "zunzunbee", + "name": "Zunzunbee", + "iot_standards": ["zigbee"] +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 867cc7a112431..46ce95ef7363f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -8242,6 +8242,12 @@ "zwave" ] }, + "zunzunbee": { + "name": "Zunzunbee", + "iot_standards": [ + "zigbee" + ] + }, "zwave_js": { "name": "Z-Wave", "integration_type": "hub", From 9b5166769a500237ba178ca5aad7948e11f941ea Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 May 2026 16:18:01 +0200 Subject: [PATCH 08/11] Add Sensereo matter brand (#169836) --- homeassistant/brands/sensereo.json | 5 +++++ homeassistant/generated/integrations.json | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 homeassistant/brands/sensereo.json diff --git a/homeassistant/brands/sensereo.json b/homeassistant/brands/sensereo.json new file mode 100644 index 0000000000000..4825bd55326dc --- /dev/null +++ b/homeassistant/brands/sensereo.json @@ -0,0 +1,5 @@ +{ + "domain": "sensereo", + "name": "Sensereo", + "iot_standards": ["matter"] +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 46ce95ef7363f..4277316a56616 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6146,6 +6146,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "sensereo": { + "name": "Sensereo", + "iot_standards": [ + "matter" + ] + }, "sensibo": { "name": "Sensibo", "integration_type": "hub", From 11ed1fe20ffc6c6a20e5c2192fff2553ce6f59e0 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Tue, 5 May 2026 17:28:20 +0300 Subject: [PATCH 09/11] Return the requested format for OpenAI TTS (#169839) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/openai_conversation/tts.py | 21 +++--- .../openai_conversation/test_tts.py | 73 +++++++++++++++++++ 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/openai_conversation/tts.py b/homeassistant/components/openai_conversation/tts.py index 1fc6979d86d41..13a310eb03dcf 100644 --- a/homeassistant/components/openai_conversation/tts.py +++ b/homeassistant/components/openai_conversation/tts.py @@ -2,7 +2,7 @@ from collections.abc import Mapping import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from openai import OpenAIError from propcache.api import cached_property @@ -164,14 +164,15 @@ async def async_get_tts_audio( client = self.entry.runtime_data response_format = options[ATTR_PREFERRED_FORMAT] - if response_format not in self._supported_formats: - # common aliases - if response_format == "ogg": - response_format = "opus" - elif response_format == "raw": - response_format = "pcm" - else: - response_format = self.default_options[ATTR_PREFERRED_FORMAT] + if response_format in ("ogg", "oga"): + codec: Literal["mp3", "opus", "aac", "flac", "wav", "pcm"] = "opus" + elif response_format == "raw": + response_format = codec = "pcm" + elif response_format not in self._supported_formats: + response_format = self.default_options[ATTR_PREFERRED_FORMAT] + codec = response_format + else: + codec = response_format try: async with client.audio.speech.with_streaming_response.create( @@ -180,7 +181,7 @@ async def async_get_tts_audio( input=message, instructions=str(options.get(CONF_PROMPT)), speed=options.get(CONF_TTS_SPEED, RECOMMENDED_TTS_SPEED), - response_format=response_format, + response_format=codec, ) as response: response_data = bytearray() async for chunk in response.iter_bytes(): diff --git a/tests/components/openai_conversation/test_tts.py b/tests/components/openai_conversation/test_tts.py index ffbe7117c1e8a..45c3a0a962042 100644 --- a/tests/components/openai_conversation/test_tts.py +++ b/tests/components/openai_conversation/test_tts.py @@ -118,6 +118,79 @@ async def test_tts( ) +@pytest.mark.parametrize( + ("preferred_format", "expected_response_format"), + [ + ("ogg", "opus"), + ("oga", "opus"), + ("mp3", "mp3"), + ], +) +@pytest.mark.usefixtures("mock_init_component") +async def test_tts_preferred_format( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_create_speech: MagicMock, + calls: list[ServiceCall], + preferred_format: str, + expected_response_format: str, +) -> None: + """Test text to speech preferred format handling.""" + mock_create_speech.return_value = [b"mock audio data"] + + await hass.services.async_call( + tts.DOMAIN, + "speak", + { + ATTR_ENTITY_ID: "tts.openai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_PREFERRED_FORMAT: preferred_format}, + }, + blocking=True, + ) + + assert len(calls) == 1 + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + mock_create_speech.assert_called_once_with( + model="gpt-4o-mini-tts", + voice="marin", + input="There is a person at the front door.", + instructions="", + speed=1.0, + response_format=expected_response_format, + ) + + +@pytest.mark.usefixtures("mock_init_component") +async def test_tts_raw_preferred_format_returns_pcm( + hass: HomeAssistant, + mock_create_speech: MagicMock, +) -> None: + """Test raw preferred format is returned as pcm.""" + tts_entity = hass.data[tts.DOMAIN].get_entity("tts.openai_tts") + mock_create_speech.return_value = [b"mock audio data"] + + result = await tts_entity.async_get_tts_audio( + "There is a person at the front door.", + "en-US", + {tts.ATTR_PREFERRED_FORMAT: "raw", tts.ATTR_VOICE: "marin"}, + ) + + assert result == ("pcm", b"mock audio data") + mock_create_speech.assert_called_once_with( + model="gpt-4o-mini-tts", + voice="marin", + input="There is a person at the front door.", + instructions="", + speed=1.0, + response_format="pcm", + ) + + @pytest.mark.usefixtures("mock_init_component") async def test_tts_error( hass: HomeAssistant, From 751765b97be9459f4602435852f3e361dc0d2758 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 May 2026 16:35:21 +0200 Subject: [PATCH 10/11] Cleanup from __future__ import annotations (#169850) --- homeassistant/components/fluss/coordinator.py | 2 -- homeassistant/components/mitsubishi_comfort/__init__.py | 2 -- homeassistant/components/mitsubishi_comfort/climate.py | 2 -- homeassistant/components/mitsubishi_comfort/config_flow.py | 2 -- homeassistant/components/mitsubishi_comfort/coordinator.py | 2 -- homeassistant/components/mitsubishi_comfort/entity.py | 2 -- homeassistant/components/overkiz/services.py | 2 -- homeassistant/helpers/check_config.py | 2 -- pylint/plugins/hass_async_load_fixtures.py | 2 -- pylint/plugins/hass_decorator.py | 2 -- pylint/plugins/hass_enforce_class_module.py | 2 -- pylint/plugins/hass_enforce_config_entry_unique_id_no_ip.py | 2 -- pylint/plugins/hass_enforce_config_flow_no_polling.py | 2 -- pylint/plugins/hass_enforce_greek_micro_char.py | 2 -- pylint/plugins/hass_enforce_runtime_data.py | 2 -- pylint/plugins/hass_enforce_sorted_platforms.py | 2 -- pylint/plugins/hass_enforce_super_call.py | 2 -- pylint/plugins/hass_enforce_type_hints.py | 2 -- pylint/plugins/hass_imports.py | 2 -- pylint/plugins/hass_inheritance.py | 2 -- tests/components/mitsubishi_comfort/conftest.py | 2 -- tests/components/mitsubishi_comfort/test_climate.py | 2 -- tests/components/mitsubishi_comfort/test_config_flow.py | 2 -- tests/components/mitsubishi_comfort/test_init.py | 2 -- 24 files changed, 48 deletions(-) diff --git a/homeassistant/components/fluss/coordinator.py b/homeassistant/components/fluss/coordinator.py index a3e9dcdab781c..5c2d9e6710433 100644 --- a/homeassistant/components/fluss/coordinator.py +++ b/homeassistant/components/fluss/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for Fluss+ integration.""" -from __future__ import annotations - import asyncio from typing import Any diff --git a/homeassistant/components/mitsubishi_comfort/__init__.py b/homeassistant/components/mitsubishi_comfort/__init__.py index 780c1cb1deb15..0cf7ede4d5711 100644 --- a/homeassistant/components/mitsubishi_comfort/__init__.py +++ b/homeassistant/components/mitsubishi_comfort/__init__.py @@ -1,7 +1,5 @@ """Mitsubishi Comfort integration for Home Assistant.""" -from __future__ import annotations - import asyncio import logging diff --git a/homeassistant/components/mitsubishi_comfort/climate.py b/homeassistant/components/mitsubishi_comfort/climate.py index 22b3bd1e2bad4..fcbd1165f1374 100644 --- a/homeassistant/components/mitsubishi_comfort/climate.py +++ b/homeassistant/components/mitsubishi_comfort/climate.py @@ -1,7 +1,5 @@ """Climate entity for Mitsubishi Comfort integration.""" -from __future__ import annotations - from typing import Any from mitsubishi_comfort import FanSpeed, IndoorUnit, Mode, VaneDirection diff --git a/homeassistant/components/mitsubishi_comfort/config_flow.py b/homeassistant/components/mitsubishi_comfort/config_flow.py index f0175547a760b..9579be6195808 100644 --- a/homeassistant/components/mitsubishi_comfort/config_flow.py +++ b/homeassistant/components/mitsubishi_comfort/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Mitsubishi Comfort integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/mitsubishi_comfort/coordinator.py b/homeassistant/components/mitsubishi_comfort/coordinator.py index 47c230f905089..38d642baf1e39 100644 --- a/homeassistant/components/mitsubishi_comfort/coordinator.py +++ b/homeassistant/components/mitsubishi_comfort/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for Mitsubishi Comfort devices.""" -from __future__ import annotations - import logging from mitsubishi_comfort import IndoorUnit, KumoStation diff --git a/homeassistant/components/mitsubishi_comfort/entity.py b/homeassistant/components/mitsubishi_comfort/entity.py index 283af2b2f9726..7599c0ff2f4f8 100644 --- a/homeassistant/components/mitsubishi_comfort/entity.py +++ b/homeassistant/components/mitsubishi_comfort/entity.py @@ -1,7 +1,5 @@ """Base entity for Mitsubishi Comfort integration.""" -from __future__ import annotations - from mitsubishi_comfort import IndoorUnit, KumoStation from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo diff --git a/homeassistant/components/overkiz/services.py b/homeassistant/components/overkiz/services.py index e42cf2b22eaf5..f32fa307e5985 100644 --- a/homeassistant/components/overkiz/services.py +++ b/homeassistant/components/overkiz/services.py @@ -1,7 +1,5 @@ """Services for the Overkiz integration.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.cover import ( diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 1982cc4f0c8cb..6cf685ea5c972 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -1,7 +1,5 @@ """Helper to check the configuration file.""" -from __future__ import annotations - from collections import OrderedDict import logging import os diff --git a/pylint/plugins/hass_async_load_fixtures.py b/pylint/plugins/hass_async_load_fixtures.py index b1680f3f280d4..a47130dd8250b 100644 --- a/pylint/plugins/hass_async_load_fixtures.py +++ b/pylint/plugins/hass_async_load_fixtures.py @@ -1,7 +1,5 @@ """Plugin for logger invocations.""" -from __future__ import annotations - from astroid import nodes from pylint.checkers import BaseChecker from pylint.lint import PyLinter diff --git a/pylint/plugins/hass_decorator.py b/pylint/plugins/hass_decorator.py index 7e509776a869b..2a777f5542881 100644 --- a/pylint/plugins/hass_decorator.py +++ b/pylint/plugins/hass_decorator.py @@ -1,7 +1,5 @@ """Plugin to check decorators.""" -from __future__ import annotations - from astroid import nodes from pylint.checkers import BaseChecker from pylint.lint import PyLinter diff --git a/pylint/plugins/hass_enforce_class_module.py b/pylint/plugins/hass_enforce_class_module.py index 13c25b203a178..82d1e0a59fab9 100644 --- a/pylint/plugins/hass_enforce_class_module.py +++ b/pylint/plugins/hass_enforce_class_module.py @@ -1,7 +1,5 @@ """Plugin for checking if class is in correct module.""" -from __future__ import annotations - from astroid import nodes from pylint.checkers import BaseChecker from pylint.lint import PyLinter diff --git a/pylint/plugins/hass_enforce_config_entry_unique_id_no_ip.py b/pylint/plugins/hass_enforce_config_entry_unique_id_no_ip.py index f41dae4ac1ec7..48593eb0b64dd 100644 --- a/pylint/plugins/hass_enforce_config_entry_unique_id_no_ip.py +++ b/pylint/plugins/hass_enforce_config_entry_unique_id_no_ip.py @@ -9,8 +9,6 @@ 16.2% of new-integration PRs across 1,100+ analyzed PRs. """ -from __future__ import annotations - from astroid import nodes from pylint.checkers import BaseChecker from pylint.lint import PyLinter diff --git a/pylint/plugins/hass_enforce_config_flow_no_polling.py b/pylint/plugins/hass_enforce_config_flow_no_polling.py index 1666633fdda7b..e1cf55dfd9fca 100644 --- a/pylint/plugins/hass_enforce_config_flow_no_polling.py +++ b/pylint/plugins/hass_enforce_config_flow_no_polling.py @@ -8,8 +8,6 @@ Found in 3.5% of new-integration PRs across 1,100+ analyzed PRs, April 2026. """ -from __future__ import annotations - from astroid import nodes from pylint.checkers import BaseChecker from pylint.lint import PyLinter diff --git a/pylint/plugins/hass_enforce_greek_micro_char.py b/pylint/plugins/hass_enforce_greek_micro_char.py index 909af66cd9e5b..cb945ff29c409 100644 --- a/pylint/plugins/hass_enforce_greek_micro_char.py +++ b/pylint/plugins/hass_enforce_greek_micro_char.py @@ -1,7 +1,5 @@ """Plugin for checking preferred coding of μ is used.""" -from __future__ import annotations - from typing import Any from astroid import nodes diff --git a/pylint/plugins/hass_enforce_runtime_data.py b/pylint/plugins/hass_enforce_runtime_data.py index b9e636c1ea68a..a72b50be8e7c1 100644 --- a/pylint/plugins/hass_enforce_runtime_data.py +++ b/pylint/plugins/hass_enforce_runtime_data.py @@ -10,8 +10,6 @@ automated enforcement. """ -from __future__ import annotations - from pathlib import Path from astroid import nodes diff --git a/pylint/plugins/hass_enforce_sorted_platforms.py b/pylint/plugins/hass_enforce_sorted_platforms.py index 5ae26a179c9d5..8a6fae9ee6991 100644 --- a/pylint/plugins/hass_enforce_sorted_platforms.py +++ b/pylint/plugins/hass_enforce_sorted_platforms.py @@ -1,7 +1,5 @@ """Plugin for checking sorted platforms list.""" -from __future__ import annotations - from astroid import nodes from pylint.checkers import BaseChecker from pylint.lint import PyLinter diff --git a/pylint/plugins/hass_enforce_super_call.py b/pylint/plugins/hass_enforce_super_call.py index b0f523aef72f9..78a726b171038 100644 --- a/pylint/plugins/hass_enforce_super_call.py +++ b/pylint/plugins/hass_enforce_super_call.py @@ -1,7 +1,5 @@ """Plugin for checking super calls.""" -from __future__ import annotations - from astroid import nodes from pylint.checkers import BaseChecker from pylint.interfaces import INFERENCE diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 87f287ef9e8aa..0633a4ed91c9b 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -1,7 +1,5 @@ """Plugin to enforce type hints on specific functions.""" -from __future__ import annotations - from dataclasses import dataclass from enum import Enum import re diff --git a/pylint/plugins/hass_imports.py b/pylint/plugins/hass_imports.py index 5df97f79bd2f6..5ad1b50980190 100644 --- a/pylint/plugins/hass_imports.py +++ b/pylint/plugins/hass_imports.py @@ -1,7 +1,5 @@ """Plugin for checking imports.""" -from __future__ import annotations - from dataclasses import dataclass import re diff --git a/pylint/plugins/hass_inheritance.py b/pylint/plugins/hass_inheritance.py index cc2a40d4a4a42..ca2037e4aed6d 100644 --- a/pylint/plugins/hass_inheritance.py +++ b/pylint/plugins/hass_inheritance.py @@ -1,7 +1,5 @@ """Plugin to enforce type hints on specific functions.""" -from __future__ import annotations - import re from astroid import nodes diff --git a/tests/components/mitsubishi_comfort/conftest.py b/tests/components/mitsubishi_comfort/conftest.py index 56f269afc9b60..49bd1b8df3f40 100644 --- a/tests/components/mitsubishi_comfort/conftest.py +++ b/tests/components/mitsubishi_comfort/conftest.py @@ -1,7 +1,5 @@ """Test fixtures for Mitsubishi Comfort integration.""" -from __future__ import annotations - from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch diff --git a/tests/components/mitsubishi_comfort/test_climate.py b/tests/components/mitsubishi_comfort/test_climate.py index d12419825e17d..d0a095f586d9b 100644 --- a/tests/components/mitsubishi_comfort/test_climate.py +++ b/tests/components/mitsubishi_comfort/test_climate.py @@ -1,7 +1,5 @@ """Tests for the Mitsubishi Comfort climate entity.""" -from __future__ import annotations - from unittest.mock import AsyncMock, MagicMock, patch from freezegun.api import FrozenDateTimeFactory diff --git a/tests/components/mitsubishi_comfort/test_config_flow.py b/tests/components/mitsubishi_comfort/test_config_flow.py index ced2b8aa3ac67..67720941a5a0b 100644 --- a/tests/components/mitsubishi_comfort/test_config_flow.py +++ b/tests/components/mitsubishi_comfort/test_config_flow.py @@ -1,7 +1,5 @@ """Tests for the Mitsubishi Comfort config flow.""" -from __future__ import annotations - from collections.abc import Generator from unittest.mock import AsyncMock, patch diff --git a/tests/components/mitsubishi_comfort/test_init.py b/tests/components/mitsubishi_comfort/test_init.py index 5df54d606a896..01267e9a6972a 100644 --- a/tests/components/mitsubishi_comfort/test_init.py +++ b/tests/components/mitsubishi_comfort/test_init.py @@ -1,7 +1,5 @@ """Tests for the Mitsubishi Comfort integration setup.""" -from __future__ import annotations - from unittest.mock import AsyncMock, MagicMock from mitsubishi_comfort import DeviceInfo From ea55ef90a6b77b1998670e823cdae5159678bc72 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 5 May 2026 10:22:22 -0500 Subject: [PATCH 11/11] Bump intents to 2026.5.5 (#169855) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 7317aea82852e..40629a05a16ee 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["hassil==3.5.0", "home-assistant-intents==2026.3.24"] + "requirements": ["hassil==3.5.0", "home-assistant-intents==2026.5.5"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b42ec228e9a12..3d31303977d17 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -40,7 +40,7 @@ hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==2.0.0 home-assistant-frontend==20260429.2 -home-assistant-intents==2026.3.24 +home-assistant-intents==2026.5.5 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 diff --git a/requirements.txt b/requirements.txt index 5bfddd729a386..6025068813756 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ ha-ffmpeg==3.2.2 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==2.0.0 -home-assistant-intents==2026.3.24 +home-assistant-intents==2026.5.5 httpx==0.28.1 ifaddr==0.2.0 infrared-protocols==2.1.0 diff --git a/requirements_all.txt b/requirements_all.txt index 062c432976592..708ccd04d4f6e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1251,7 +1251,7 @@ holidays==0.95 home-assistant-frontend==20260429.2 # homeassistant.components.conversation -home-assistant-intents==2026.3.24 +home-assistant-intents==2026.5.5 # homeassistant.components.homekit homekit-audio-proxy==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2d926a476f975..f4c7c7e8ba5e8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1115,7 +1115,7 @@ holidays==0.95 home-assistant-frontend==20260429.2 # homeassistant.components.conversation -home-assistant-intents==2026.3.24 +home-assistant-intents==2026.5.5 # homeassistant.components.homekit homekit-audio-proxy==1.2.1