From 38634ddd555fae3e80060f9ba2679a77a2280419 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 6 May 2026 17:48:35 +0200 Subject: [PATCH 01/11] Fix hassio auth IndexError on Supervisor Unix socket requests (#169911) --- homeassistant/components/hassio/auth.py | 21 ++++++----- tests/components/hassio/test_auth.py | 47 ++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/hassio/auth.py b/homeassistant/components/hassio/auth.py index 8589bc0f134705..9c9d7cc710ef8a 100644 --- a/homeassistant/components/hassio/auth.py +++ b/homeassistant/components/hassio/auth.py @@ -12,6 +12,7 @@ from homeassistant.auth.models import User from homeassistant.auth.providers import homeassistant as auth_ha from homeassistant.components.http import KEY_HASS, KEY_HASS_USER, HomeAssistantView +from homeassistant.components.http.const import is_supervisor_unix_socket_request from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv @@ -41,14 +42,18 @@ def __init__(self, hass: HomeAssistant, user: User) -> None: def _check_access(self, request: web.Request) -> None: """Check if this call is from Supervisor.""" - # Check caller IP - hassio_ip = os.environ["SUPERVISOR"].split(":")[0] - assert request.transport - if ip_address(request.transport.get_extra_info("peername")[0]) != ip_address( - hassio_ip - ): - _LOGGER.error("Invalid auth request from %s", request.remote) - raise HTTPUnauthorized + # Requests over the Supervisor Unix socket are authenticated by the + # http auth middleware as the Supervisor user, so the caller-IP check + # below does not apply (and would crash, since `peername` is empty for + # Unix sockets). The user-ID check still runs to ensure only the + # Supervisor user can reach this endpoint. + if not is_supervisor_unix_socket_request(request): + hassio_ip = os.environ["SUPERVISOR"].split(":")[0] + assert request.transport + peername = request.transport.get_extra_info("peername") + if not peername or ip_address(peername[0]) != ip_address(hassio_ip): + _LOGGER.error("Invalid auth request from %s", request.remote) + raise HTTPUnauthorized # Check caller token if request[KEY_HASS_USER].id != self.user.id: diff --git a/tests/components/hassio/test_auth.py b/tests/components/hassio/test_auth.py index ad96b58e99db8e..7f5e4ba331b661 100644 --- a/tests/components/hassio/test_auth.py +++ b/tests/components/hassio/test_auth.py @@ -1,11 +1,17 @@ """The tests for the hassio component.""" +from contextlib import AbstractContextManager, ExitStack as DefaultContext from http import HTTPStatus -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch from aiohttp.test_utils import TestClient +from aiohttp.web_exceptions import HTTPUnauthorized +import pytest from homeassistant.auth.providers.homeassistant import InvalidAuth +from homeassistant.components.hassio.auth import HassIOBaseAuth +from homeassistant.components.hassio.const import DATA_CONFIG_STORE +from homeassistant.core import HomeAssistant async def test_auth_success(hassio_client_supervisor: TestClient) -> None: @@ -162,6 +168,45 @@ async def test_password_fails_no_auth(hassio_noauth_client: TestClient) -> None: assert resp.status == HTTPStatus.UNAUTHORIZED +@pytest.mark.parametrize( + ("peername", "unix_socket", "expectation"), + [ + # Unix socket transports report an empty string for peername. Before + # the fix this raised IndexError on `peername[0]`. + ("", True, DefaultContext()), + # Defensive: a TCP transport with no peername at all should be + # rejected, not crash. + (None, False, pytest.raises(HTTPUnauthorized)), + ], +) +@pytest.mark.usefixtures("hassio_stubs") +async def test_check_access_unix_socket_or_missing_peername( + hass: HomeAssistant, + peername: str | None, + unix_socket: bool, + expectation: AbstractContextManager, +) -> None: + """Test _check_access handles Unix socket requests and missing peername.""" + hassio_user_id = hass.data[DATA_CONFIG_STORE].data.hassio_user + assert hassio_user_id is not None + user = await hass.auth.async_get_user(hassio_user_id) + assert user is not None + + auth_view = HassIOBaseAuth(hass, user) + request = MagicMock() + request.transport.get_extra_info.return_value = peername + request.__getitem__.return_value = user + + with ( + patch( + "homeassistant.components.hassio.auth.is_supervisor_unix_socket_request", + return_value=unix_socket, + ), + expectation, + ): + auth_view._check_access(request) + + async def test_password_no_user(hassio_client_supervisor: TestClient) -> None: """Test changing password for invalid user.""" resp = await hassio_client_supervisor.post( From fa265b18ce69cc8e7064b59b5e53b8324b0ffa72 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 6 May 2026 18:12:13 +0200 Subject: [PATCH 02/11] Shorten docker publish job name (#169926) --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 0e5b70737839ab..6aaef512a7587b 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -323,7 +323,7 @@ jobs: exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]' publish_container: - name: Publish meta container for ${{ matrix.registry }} + name: Publish to ${{ matrix.registry }} environment: ${{ needs.init.outputs.channel }} if: github.repository_owner == 'home-assistant' needs: ["init", "build_base"] From ac84a14846889215138c5c7994cd8986babc5f61 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 6 May 2026 14:04:13 -0400 Subject: [PATCH 03/11] Bump serialx to 1.7.1 (#169928) --- homeassistant/components/acer_projector/manifest.json | 2 +- homeassistant/components/serial/manifest.json | 2 +- homeassistant/components/usb/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/acer_projector/manifest.json b/homeassistant/components/acer_projector/manifest.json index 480a72bd09ece9..45d6256f0e8ec3 100644 --- a/homeassistant/components/acer_projector/manifest.json +++ b/homeassistant/components/acer_projector/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/acer_projector", "iot_class": "local_polling", "quality_scale": "legacy", - "requirements": ["serialx==1.7.0"] + "requirements": ["serialx==1.7.1"] } diff --git a/homeassistant/components/serial/manifest.json b/homeassistant/components/serial/manifest.json index b7296c584c96b8..b87fc69491840f 100644 --- a/homeassistant/components/serial/manifest.json +++ b/homeassistant/components/serial/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/serial", "iot_class": "local_polling", - "requirements": ["serialx==1.7.0"] + "requirements": ["serialx==1.7.1"] } diff --git a/homeassistant/components/usb/manifest.json b/homeassistant/components/usb/manifest.json index e2f6c3db62bc64..222df3e2c06e41 100644 --- a/homeassistant/components/usb/manifest.json +++ b/homeassistant/components/usb/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["aiousbwatcher==1.1.2", "serialx==1.7.0"] + "requirements": ["aiousbwatcher==1.1.2", "serialx==1.7.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8ab0eea85f9f93..6bde0a182b8db4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -63,7 +63,7 @@ PyTurboJPEG==1.8.3 PyYAML==6.0.3 requests==2.33.1 securetar==2026.4.1 -serialx==1.7.0 +serialx==1.7.1 SQLAlchemy==2.0.49 standard-aifc==3.13.0 standard-telnetlib==3.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 843ecc3862e9e4..baed03d51ab8bb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2951,7 +2951,7 @@ sentry-sdk==2.48.0 # homeassistant.components.acer_projector # homeassistant.components.serial # homeassistant.components.usb -serialx==1.7.0 +serialx==1.7.1 # homeassistant.components.sfr_box sfrbox-api==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8976d4e927a064..cfe98cabba4234 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2517,7 +2517,7 @@ sentry-sdk==2.48.0 # homeassistant.components.acer_projector # homeassistant.components.serial # homeassistant.components.usb -serialx==1.7.0 +serialx==1.7.1 # homeassistant.components.sfr_box sfrbox-api==0.1.1 From 1e5992f2b5925355dbcb9a1ad03e7773b91e8b57 Mon Sep 17 00:00:00 2001 From: Andriy Kushnir Date: Wed, 6 May 2026 21:33:15 +0300 Subject: [PATCH 04/11] Remove myself as codeowner for roomba (#169922) --- CODEOWNERS | 4 ++-- homeassistant/components/roomba/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 76e02ea3546de4..2ba6cac48814fb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1495,8 +1495,8 @@ CLAUDE.md @home-assistant/core /tests/components/roku/ @ctalkington /homeassistant/components/romy/ @xeniter /tests/components/romy/ @xeniter -/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous -/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous +/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn +/tests/components/roomba/ @pschmitt @cyr-ius @shenxn /homeassistant/components/roon/ @pavoni /tests/components/roon/ @pavoni /homeassistant/components/route_b_smart_meter/ @SeraphicRav diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index 1ded2f6a9ce8eb..787889cdcf7a8d 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -1,7 +1,7 @@ { "domain": "roomba", "name": "iRobot Roomba and Braava", - "codeowners": ["@pschmitt", "@cyr-ius", "@shenxn", "@Orhideous"], + "codeowners": ["@pschmitt", "@cyr-ius", "@shenxn"], "config_flow": true, "dhcp": [ { From 27a8d185c983f36d2bb4ae4f80747e407895d0e6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 May 2026 21:43:29 +0200 Subject: [PATCH 05/11] Add StatelessEntityTriggerBase base class (#169937) --- homeassistant/components/button/trigger.py | 26 +----- homeassistant/components/doorbell/trigger.py | 25 +----- homeassistant/components/event/trigger.py | 23 ++---- homeassistant/components/scene/trigger.py | 26 +----- homeassistant/helpers/trigger.py | 24 ++++++ tests/helpers/test_trigger.py | 83 ++++++++++++++++++++ 6 files changed, 123 insertions(+), 84 deletions(-) diff --git a/homeassistant/components/button/trigger.py b/homeassistant/components/button/trigger.py index ea69b06b5115be..8d5402401c79cb 100644 --- a/homeassistant/components/button/trigger.py +++ b/homeassistant/components/button/trigger.py @@ -1,36 +1,16 @@ """Provides triggers for buttons.""" -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.helpers.automation import DomainSpec -from homeassistant.helpers.trigger import ( - ENTITY_STATE_TRIGGER_SCHEMA, - EntityTriggerBase, - Trigger, -) +from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger from . import DOMAIN -class ButtonPressedTrigger(EntityTriggerBase): +class ButtonPressedTrigger(StatelessEntityTriggerBase): """Trigger for button entity presses.""" _domain_specs = {DOMAIN: DomainSpec()} - _schema = ENTITY_STATE_TRIGGER_SCHEMA - - def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state is valid and different from the current state.""" - - # UNKNOWN is a valid from_state, otherwise the first time the button is pressed - # would not trigger - if from_state.state == STATE_UNAVAILABLE: - return False - - return from_state.state != to_state.state - - def is_valid_state(self, state: State) -> bool: - """Check if the new state is not invalid.""" - return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) TRIGGERS: dict[str, type[Trigger]] = { diff --git a/homeassistant/components/doorbell/trigger.py b/homeassistant/components/doorbell/trigger.py index 04f011c80726d6..420d3b244d526a 100644 --- a/homeassistant/components/doorbell/trigger.py +++ b/homeassistant/components/doorbell/trigger.py @@ -6,39 +6,22 @@ DoorbellEventType, EventDeviceClass, ) -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State from homeassistant.helpers.automation import DomainSpec -from homeassistant.helpers.trigger import ( - ENTITY_STATE_TRIGGER_SCHEMA, - EntityTriggerBase, - Trigger, -) +from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger -class DoorbellRangTrigger(EntityTriggerBase): +class DoorbellRangTrigger(StatelessEntityTriggerBase): """Trigger for doorbell event entity when a ring event is received.""" _domain_specs = {EVENT_DOMAIN: DomainSpec(device_class=EventDeviceClass.DOORBELL)} - _schema = ENTITY_STATE_TRIGGER_SCHEMA def is_valid_state(self, state: State) -> bool: """Check if the entity is available and the event type is ring.""" - return ( - state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) - and state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING + return super().is_valid_state(state) and ( + state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING ) - def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state is valid and different from the current state.""" - - # UNKNOWN is a valid from_state, otherwise the first time the event is received - # would not trigger - if from_state.state == STATE_UNAVAILABLE: - return False - - return from_state.state != to_state.state - TRIGGERS: dict[str, type[Trigger]] = { "rang": DoorbellRangTrigger, diff --git a/homeassistant/components/event/trigger.py b/homeassistant/components/event/trigger.py index aeff81988ba168..8f8c05862a96c4 100644 --- a/homeassistant/components/event/trigger.py +++ b/homeassistant/components/event/trigger.py @@ -2,13 +2,13 @@ import voluptuous as vol -from homeassistant.const import CONF_OPTIONS, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import CONF_OPTIONS from homeassistant.core import HomeAssistant, State from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( ENTITY_STATE_TRIGGER_SCHEMA, - EntityTriggerBase, + StatelessEntityTriggerBase, Trigger, TriggerConfig, ) @@ -28,7 +28,7 @@ ) -class EventReceivedTrigger(EntityTriggerBase): +class EventReceivedTrigger(StatelessEntityTriggerBase): """Trigger for event entity when it receives a matching event.""" _domain_specs = {DOMAIN: DomainSpec()} @@ -39,21 +39,10 @@ def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: super().__init__(hass, config) self._event_types = set(self._options[CONF_EVENT_TYPE]) - def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state is valid and different from the current state.""" - - # UNKNOWN is a valid from_state, otherwise the first time the event is received - # would not trigger - if from_state.state == STATE_UNAVAILABLE: - return False - - return from_state.state != to_state.state - def is_valid_state(self, state: State) -> bool: - """Check if the event type is valid and matches one of the configured types.""" - return ( - state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) - and state.attributes.get(ATTR_EVENT_TYPE) in self._event_types + """Check if the event type matches one of the configured types.""" + return super().is_valid_state(state) and ( + state.attributes.get(ATTR_EVENT_TYPE) in self._event_types ) diff --git a/homeassistant/components/scene/trigger.py b/homeassistant/components/scene/trigger.py index 15f14f8c38acbd..cefeb14c7bbbea 100644 --- a/homeassistant/components/scene/trigger.py +++ b/homeassistant/components/scene/trigger.py @@ -1,36 +1,16 @@ """Provides triggers for scenes.""" -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.helpers.automation import DomainSpec -from homeassistant.helpers.trigger import ( - ENTITY_STATE_TRIGGER_SCHEMA, - EntityTriggerBase, - Trigger, -) +from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger from . import DOMAIN -class SceneActivatedTrigger(EntityTriggerBase): +class SceneActivatedTrigger(StatelessEntityTriggerBase): """Trigger for scene entity activations.""" _domain_specs = {DOMAIN: DomainSpec()} - _schema = ENTITY_STATE_TRIGGER_SCHEMA - - def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state is valid and different from the current state.""" - - # UNKNOWN is a valid from_state, otherwise the first time the scene is activated - # it would not trigger - if from_state.state == STATE_UNAVAILABLE: - return False - - return from_state.state != to_state.state - - def is_valid_state(self, state: State) -> bool: - """Check if the new state is not invalid.""" - return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) TRIGGERS: dict[str, type[Trigger]] = { diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 55ec0445bfa5b5..af9c700aa1d4ba 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -626,6 +626,30 @@ def is_valid_state(self, state: State) -> bool: ) +class StatelessEntityTriggerBase(EntityTriggerBase): + """Trigger for entities that don't carry meaningful state. + + Used for stateless entities (buttons, scenes, doorbells, events) + whose `state.state` is just a timestamp of the last activation. + """ + + _schema: vol.Schema = ENTITY_STATE_TRIGGER_SCHEMA + + def is_valid_transition(self, from_state: State, to_state: State) -> bool: + """Check if the origin state is available and the state has changed. + + STATE_UNKNOWN is allowed as the origin state so the first + activation fires. + """ + if from_state.state == STATE_UNAVAILABLE: + return False + return from_state.state != to_state.state + + def is_valid_state(self, state: State) -> bool: + """Check that the entity has been activated at least once.""" + return state.state not in self._excluded_states + + NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend( { vol.Required(CONF_OPTIONS, default={}): vol.All( diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 95980ad1a754fd..25e4be1c4984dc 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -57,6 +57,7 @@ EntityNumericalStateCrossedThresholdTriggerWithUnitBase, EntityTriggerBase, PluggableAction, + StatelessEntityTriggerBase, Trigger, TriggerActionRunner, TriggerConfig, @@ -3098,6 +3099,88 @@ async def test_make_entity_origin_state_trigger( assert not trig.is_valid_state(from_state) +class _ActivatedTrigger(StatelessEntityTriggerBase): + """Test trigger leaf for StatelessEntityTriggerBase.""" + + _domain_specs = {"test": DomainSpec()} + + +async def _arm_activated_trigger( + hass: HomeAssistant, + entity_ids: list[str], + calls: list[dict[str, Any]], +) -> CALLBACK_TYPE: + """Set up _ActivatedTrigger via async_initialize_triggers.""" + + async def async_get_triggers( + hass: HomeAssistant, + ) -> dict[str, type[Trigger]]: + return {"activated": _ActivatedTrigger} + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers)) + + trigger_config = { + CONF_PLATFORM: "test.activated", + CONF_TARGET: {CONF_ENTITY_ID: entity_ids}, + } + + log = logging.getLogger(__name__) + + @callback + def action(run_variables: dict[str, Any], context: Context | None = None) -> None: + calls.append(run_variables["trigger"]) + + validated_config = await async_validate_trigger_config(hass, [trigger_config]) + return await async_initialize_triggers( + hass, + validated_config, + action, + domain="test", + name="test_activated", + log_cb=log.log, + ) + + +@pytest.mark.parametrize( + ("initial_state", "sequence", "expected_calls"), + [ + (STATE_UNKNOWN, ["2026-05-06T12:00:00+00:00", "2026-05-06T12:00:01+00:00"], 2), + (STATE_UNAVAILABLE, ["2026-05-06T12:00:00+00:00"], 0), + ("2026-05-06T12:00:00+00:00", [STATE_UNAVAILABLE], 0), + ("2026-05-06T12:00:00+00:00", [STATE_UNKNOWN], 0), + ("2026-05-06T12:00:00+00:00", ["2026-05-06T12:00:00+00:00"], 0), + ], +) +async def test_stateless_entity_trigger( + hass: HomeAssistant, + initial_state: str, + sequence: list[str], + expected_calls: int, +) -> None: + """Test StatelessEntityTriggerBase end-to-end via a mocked platform. + + StatelessEntityTriggerBase covers entities (buttons, scenes, + doorbells, events) that have no meaningful prior state — STATE_UNKNOWN + must be a valid origin so the first activation after startup fires, + but UNAVAILABLE/UNKNOWN are never valid target states. + """ + entity_id = "test.bell" + hass.states.async_set(entity_id, initial_state) + await hass.async_block_till_done() + + calls: list[dict[str, Any]] = [] + unsub = await _arm_activated_trigger(hass, [entity_id], calls) + + for state in sequence: + hass.states.async_set(entity_id, state) + await hass.async_block_till_done() + + assert len(calls) == expected_calls + + unsub() + + class _OffToOnTrigger(EntityTriggerBase): """Test trigger that fires when state becomes 'on'.""" From 65bc4bf1d0dc94abf076185a14cf44c8d095a918 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 May 2026 21:53:40 +0200 Subject: [PATCH 06/11] Add missing trigger and condition tests (#169945) --- tests/components/battery/test_condition.py | 4 +++ tests/components/battery/test_trigger.py | 6 +++++ tests/components/calendar/test_trigger.py | 25 +++++++++++++++++ tests/components/climate/test_condition.py | 12 +++++++++ tests/components/climate/test_trigger.py | 25 +++++++++++++++++ tests/components/counter/test_condition.py | 14 +++++++--- tests/components/cover/test_condition.py | 13 ++++++--- tests/components/cover/test_trigger.py | 13 ++++++--- tests/components/humidifier/test_condition.py | 5 ++++ tests/components/humidifier/test_trigger.py | 1 + .../components/illuminance/test_condition.py | 4 +++ tests/components/illuminance/test_trigger.py | 6 +++++ tests/components/light/test_condition.py | 4 +++ tests/components/light/test_trigger.py | 13 +++++++++ tests/components/moisture/test_condition.py | 4 +++ tests/components/moisture/test_trigger.py | 6 +++++ tests/components/todo/test_condition.py | 4 +++ .../components/water_heater/test_condition.py | 15 +++++++++++ tests/components/water_heater/test_trigger.py | 27 +++++++++++++++++++ 19 files changed, 191 insertions(+), 10 deletions(-) diff --git a/tests/components/battery/test_condition.py b/tests/components/battery/test_condition.py index e0cb7d4be85e3c..ab3f5541adb4a9 100644 --- a/tests/components/battery/test_condition.py +++ b/tests/components/battery/test_condition.py @@ -63,6 +63,9 @@ async def test_battery_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +_LEVEL_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), @@ -71,6 +74,7 @@ async def test_battery_conditions_gated_by_labs_flag( ("battery.is_not_low", {}, True, True), ("battery.is_charging", {}, True, True), ("battery.is_not_charging", {}, True, True), + ("battery.is_level", _LEVEL_THRESHOLD, True, True), ], ) async def test_battery_condition_options_validation( diff --git a/tests/components/battery/test_trigger.py b/tests/components/battery/test_trigger.py index b737515577df0a..1bec0b7b2b73f8 100644 --- a/tests/components/battery/test_trigger.py +++ b/tests/components/battery/test_trigger.py @@ -63,6 +63,10 @@ async def test_battery_triggers_gated_by_labs_flag( await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key) +_LEVEL_CHANGED_THRESHOLD = {"threshold": {"type": "any"}} +_LEVEL_CROSSED_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), @@ -71,6 +75,8 @@ async def test_battery_triggers_gated_by_labs_flag( ("battery.not_low", {}, True, True), ("battery.started_charging", {}, True, True), ("battery.stopped_charging", {}, True, True), + ("battery.level_changed", _LEVEL_CHANGED_THRESHOLD, False, False), + ("battery.level_crossed_threshold", _LEVEL_CROSSED_THRESHOLD, True, True), ], ) async def test_battery_trigger_options_validation( diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index 018ea82a7a1c2f..9ed5628514ed12 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -56,10 +56,35 @@ async_mock_service, mock_device_registry, ) +from tests.components.common import assert_trigger_options_supported _LOGGER = logging.getLogger(__name__) +@pytest.mark.parametrize( + ("trigger_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("calendar.event_started", {}, False, False), + ("calendar.event_ended", {}, False, False), + ], +) +async def test_calendar_trigger_options_validation( + hass: HomeAssistant, + trigger_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that calendar triggers support the expected options.""" + await assert_trigger_options_supported( + hass, + trigger_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @dataclass class TriggerFormat: """Abstraction for different trigger configuration formats.""" diff --git a/tests/components/climate/test_condition.py b/tests/components/climate/test_condition.py index f2c9c9230bac07..5b7464bc882582 100644 --- a/tests/components/climate/test_condition.py +++ b/tests/components/climate/test_condition.py @@ -60,6 +60,15 @@ async def test_climate_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +_HUMIDITY_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} +_TEMPERATURE_THRESHOLD = { + "threshold": { + "type": "above", + "value": {"number": 20, "unit_of_measurement": UnitOfTemperature.CELSIUS}, + } +} + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), @@ -69,6 +78,9 @@ async def test_climate_conditions_gated_by_labs_flag( ("climate.is_cooling", {}, True, True), ("climate.is_drying", {}, True, True), ("climate.is_heating", {}, True, True), + ("climate.is_hvac_mode", {"hvac_mode": [HVACMode.HEAT]}, True, True), + ("climate.target_humidity", _HUMIDITY_THRESHOLD, True, True), + ("climate.target_temperature", _TEMPERATURE_THRESHOLD, True, True), ], ) async def test_climate_condition_options_validation( diff --git a/tests/components/climate/test_trigger.py b/tests/components/climate/test_trigger.py index f89fed3fe03372..8eceb14202f63c 100644 --- a/tests/components/climate/test_trigger.py +++ b/tests/components/climate/test_trigger.py @@ -67,6 +67,16 @@ async def test_climate_triggers_gated_by_labs_flag( await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key) +_CHANGED_THRESHOLD = {"threshold": {"type": "any"}} +_HUMIDITY_CROSSED_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} +_TEMPERATURE_CROSSED_THRESHOLD = { + "threshold": { + "type": "above", + "value": {"number": 20, "unit_of_measurement": UnitOfTemperature.CELSIUS}, + } +} + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), @@ -76,6 +86,21 @@ async def test_climate_triggers_gated_by_labs_flag( ("climate.started_heating", {}, True, True), ("climate.turned_off", {}, True, True), ("climate.turned_on", {}, True, True), + ("climate.hvac_mode_changed", {"hvac_mode": [HVACMode.HEAT]}, True, True), + ("climate.target_humidity_changed", _CHANGED_THRESHOLD, False, False), + ( + "climate.target_humidity_crossed_threshold", + _HUMIDITY_CROSSED_THRESHOLD, + True, + True, + ), + ("climate.target_temperature_changed", _CHANGED_THRESHOLD, False, False), + ( + "climate.target_temperature_crossed_threshold", + _TEMPERATURE_CROSSED_THRESHOLD, + True, + True, + ), ], ) async def test_climate_trigger_options_validation( diff --git a/tests/components/counter/test_condition.py b/tests/components/counter/test_condition.py index 9c94471c43efa8..d7fb2db598c834 100644 --- a/tests/components/counter/test_condition.py +++ b/tests/components/counter/test_condition.py @@ -25,11 +25,17 @@ async def target_counters(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "counter") -async def test_counter_condition_gated_by_labs_flag( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture +@pytest.mark.parametrize( + "condition", + [ + "counter.is_value", + ], +) +async def test_counter_conditions_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str ) -> None: - """Test the counter condition is gated by the labs flag.""" - await assert_condition_gated_by_labs_flag(hass, caplog, "counter.is_value") + """Test the counter conditions are gated by the labs flag.""" + await assert_condition_gated_by_labs_flag(hass, caplog, condition) _PLAIN_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} diff --git a/tests/components/cover/test_condition.py b/tests/components/cover/test_condition.py index 5d74a0dc0d2517..6a54d89fb71c2d 100644 --- a/tests/components/cover/test_condition.py +++ b/tests/components/cover/test_condition.py @@ -39,9 +39,16 @@ async def target_covers(hass: HomeAssistant) -> dict[str, list[str]]: @pytest.mark.parametrize( "condition", [ - condition - for _, is_open, is_closed in DEVICE_CLASS_CONDITIONS - for condition in (is_open, is_closed) + "cover.awning_is_closed", + "cover.awning_is_open", + "cover.blind_is_closed", + "cover.blind_is_open", + "cover.curtain_is_closed", + "cover.curtain_is_open", + "cover.shade_is_closed", + "cover.shade_is_open", + "cover.shutter_is_closed", + "cover.shutter_is_open", ], ) async def test_cover_conditions_gated_by_labs_flag( diff --git a/tests/components/cover/test_trigger.py b/tests/components/cover/test_trigger.py index 9ac42b483c8fe5..fce1fda9cbb4ee 100644 --- a/tests/components/cover/test_trigger.py +++ b/tests/components/cover/test_trigger.py @@ -38,9 +38,16 @@ async def target_covers(hass: HomeAssistant) -> dict[str, list[str]]: @pytest.mark.parametrize( "trigger_key", [ - trigger - for _, opened, closed in DEVICE_CLASS_TRIGGERS - for trigger in (opened, closed) + "cover.awning_closed", + "cover.awning_opened", + "cover.blind_closed", + "cover.blind_opened", + "cover.curtain_closed", + "cover.curtain_opened", + "cover.shade_closed", + "cover.shade_opened", + "cover.shutter_closed", + "cover.shutter_opened", ], ) async def test_cover_triggers_gated_by_labs_flag( diff --git a/tests/components/humidifier/test_condition.py b/tests/components/humidifier/test_condition.py index 4eebf5d4a5bc0d..66d23022876ff4 100644 --- a/tests/components/humidifier/test_condition.py +++ b/tests/components/humidifier/test_condition.py @@ -64,6 +64,9 @@ async def test_humidifier_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +_HUMIDITY_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), @@ -72,6 +75,8 @@ async def test_humidifier_conditions_gated_by_labs_flag( ("humidifier.is_on", {}, True, True), ("humidifier.is_drying", {}, True, True), ("humidifier.is_humidifying", {}, True, True), + ("humidifier.is_mode", {"mode": ["normal"]}, True, True), + ("humidifier.is_target_humidity", _HUMIDITY_THRESHOLD, True, True), ], ) async def test_humidifier_condition_options_validation( diff --git a/tests/components/humidifier/test_trigger.py b/tests/components/humidifier/test_trigger.py index 1c4b477e5acb20..f928acdd979736 100644 --- a/tests/components/humidifier/test_trigger.py +++ b/tests/components/humidifier/test_trigger.py @@ -68,6 +68,7 @@ async def test_humidifier_triggers_gated_by_labs_flag( ("humidifier.started_humidifying", {}, True, True), ("humidifier.turned_on", {}, True, True), ("humidifier.turned_off", {}, True, True), + ("humidifier.mode_changed", {"mode": ["normal"]}, True, True), ], ) async def test_humidifier_trigger_options_validation( diff --git a/tests/components/illuminance/test_condition.py b/tests/components/illuminance/test_condition.py index 614ea7146ffd03..e44b3687b7355d 100644 --- a/tests/components/illuminance/test_condition.py +++ b/tests/components/illuminance/test_condition.py @@ -56,12 +56,16 @@ async def test_illuminance_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +_ILLUMINANCE_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ ("illuminance.is_detected", {}, True, True), ("illuminance.is_not_detected", {}, True, True), + ("illuminance.is_value", _ILLUMINANCE_THRESHOLD, True, True), ], ) async def test_illuminance_condition_options_validation( diff --git a/tests/components/illuminance/test_trigger.py b/tests/components/illuminance/test_trigger.py index 1b18951def02d1..0e464fe87ff219 100644 --- a/tests/components/illuminance/test_trigger.py +++ b/tests/components/illuminance/test_trigger.py @@ -58,12 +58,18 @@ async def test_illuminance_triggers_gated_by_labs_flag( await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key) +_CHANGED_THRESHOLD = {"threshold": {"type": "any"}} +_CROSSED_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ ("illuminance.detected", {}, True, True), ("illuminance.cleared", {}, True, True), + ("illuminance.changed", _CHANGED_THRESHOLD, False, False), + ("illuminance.crossed_threshold", _CROSSED_THRESHOLD, True, True), ], ) async def test_illuminance_trigger_options_validation( diff --git a/tests/components/light/test_condition.py b/tests/components/light/test_condition.py index b8f19f8e4ee08b..26caeaf5f16764 100644 --- a/tests/components/light/test_condition.py +++ b/tests/components/light/test_condition.py @@ -47,12 +47,16 @@ async def test_light_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +_BRIGHTNESS_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ ("light.is_off", {}, True, True), ("light.is_on", {}, True, True), + ("light.is_brightness", _BRIGHTNESS_THRESHOLD, True, True), ], ) async def test_light_condition_options_validation( diff --git a/tests/components/light/test_trigger.py b/tests/components/light/test_trigger.py index 4f27bd3f16d1e1..c854587b6ea67d 100644 --- a/tests/components/light/test_trigger.py +++ b/tests/components/light/test_trigger.py @@ -53,12 +53,25 @@ async def test_light_triggers_gated_by_labs_flag( await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key) +_CHANGED_THRESHOLD = {"threshold": {"type": "any"}} +_BRIGHTNESS_CROSSED_THRESHOLD = { + "threshold": {"type": "above", "value": {"number": 50}} +} + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ ("light.turned_on", {}, True, True), ("light.turned_off", {}, True, True), + ("light.brightness_changed", _CHANGED_THRESHOLD, False, False), + ( + "light.brightness_crossed_threshold", + _BRIGHTNESS_CROSSED_THRESHOLD, + True, + True, + ), ], ) async def test_light_trigger_options_validation( diff --git a/tests/components/moisture/test_condition.py b/tests/components/moisture/test_condition.py index 7c636a7c90bc46..124a78aec4faa1 100644 --- a/tests/components/moisture/test_condition.py +++ b/tests/components/moisture/test_condition.py @@ -56,12 +56,16 @@ async def test_moisture_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +_MOISTURE_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ ("moisture.is_detected", {}, True, True), ("moisture.is_not_detected", {}, True, True), + ("moisture.is_value", _MOISTURE_THRESHOLD, True, True), ], ) async def test_moisture_condition_options_validation( diff --git a/tests/components/moisture/test_trigger.py b/tests/components/moisture/test_trigger.py index 83e0946d18c335..6fd3acd75f4070 100644 --- a/tests/components/moisture/test_trigger.py +++ b/tests/components/moisture/test_trigger.py @@ -58,12 +58,18 @@ async def test_moisture_triggers_gated_by_labs_flag( await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key) +_CHANGED_THRESHOLD = {"threshold": {"type": "any"}} +_CROSSED_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ ("moisture.detected", {}, True, True), ("moisture.cleared", {}, True, True), + ("moisture.changed", _CHANGED_THRESHOLD, False, False), + ("moisture.crossed_threshold", _CROSSED_THRESHOLD, True, True), ], ) async def test_moisture_trigger_options_validation( diff --git a/tests/components/todo/test_condition.py b/tests/components/todo/test_condition.py index 9723d1cc2a063a..a4fc6a973c69d0 100644 --- a/tests/components/todo/test_condition.py +++ b/tests/components/todo/test_condition.py @@ -39,11 +39,15 @@ async def test_todo_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +_TODO_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 5}}} + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ ("todo.all_completed", {}, True, True), + ("todo.incomplete", _TODO_THRESHOLD, True, True), ], ) async def test_todo_condition_options_validation( diff --git a/tests/components/water_heater/test_condition.py b/tests/components/water_heater/test_condition.py index 79bce1e431d493..2d1d1c375a926d 100644 --- a/tests/components/water_heater/test_condition.py +++ b/tests/components/water_heater/test_condition.py @@ -72,12 +72,27 @@ async def test_water_heater_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +_TEMPERATURE_THRESHOLD = { + "threshold": { + "type": "above", + "value": {"number": 20, "unit_of_measurement": UnitOfTemperature.CELSIUS}, + } +} + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ ("water_heater.is_off", {}, True, True), ("water_heater.is_on", {}, True, True), + ( + "water_heater.is_operation_mode", + {"operation_mode": [STATE_ECO]}, + True, + True, + ), + ("water_heater.is_target_temperature", _TEMPERATURE_THRESHOLD, True, True), ], ) async def test_water_heater_condition_options_validation( diff --git a/tests/components/water_heater/test_trigger.py b/tests/components/water_heater/test_trigger.py index f0a28bbeed94f8..e200f93b8a2609 100644 --- a/tests/components/water_heater/test_trigger.py +++ b/tests/components/water_heater/test_trigger.py @@ -65,12 +65,39 @@ async def test_water_heater_triggers_gated_by_labs_flag( await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key) +_CHANGED_THRESHOLD = {"threshold": {"type": "any"}} +_CROSSED_THRESHOLD = { + "threshold": { + "type": "above", + "value": {"number": 20, "unit_of_measurement": UnitOfTemperature.CELSIUS}, + } +} + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ ("water_heater.turned_off", {}, True, True), ("water_heater.turned_on", {}, True, True), + ( + "water_heater.operation_mode_changed", + {"operation_mode": [STATE_ECO]}, + True, + True, + ), + ( + "water_heater.target_temperature_changed", + _CHANGED_THRESHOLD, + False, + False, + ), + ( + "water_heater.target_temperature_crossed_threshold", + _CROSSED_THRESHOLD, + True, + True, + ), ], ) async def test_water_heater_trigger_options_validation( From b8baa3271b73b8d6af90811fb75ded0821517b7c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 6 May 2026 22:08:38 +0200 Subject: [PATCH 07/11] Bump holidays to 0.96 (#169939) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 7bb5d03af95208..b2199a39ad1906 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.95", "babel==2.15.0"] + "requirements": ["holidays==0.96", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 09d58507668adf..e061de1eba867d 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.95"] + "requirements": ["holidays==0.96"] } diff --git a/requirements_all.txt b/requirements_all.txt index baed03d51ab8bb..2bc5f3cfb0a78b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1245,7 +1245,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.95 +holidays==0.96 # homeassistant.components.frontend home-assistant-frontend==20260429.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cfe98cabba4234..265dc7804262e1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1109,7 +1109,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.95 +holidays==0.96 # homeassistant.components.frontend home-assistant-frontend==20260429.3 From 7da49570b5fb0f24b7bfffc668b31b8df9d06726 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 May 2026 22:16:55 +0200 Subject: [PATCH 08/11] Add support for options to todo triggers (#169947) --- homeassistant/components/todo/trigger.py | 3 +- tests/components/todo/test_trigger.py | 45 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/todo/trigger.py b/homeassistant/components/todo/trigger.py index 8387850f6e588a..cdb3bd5dd64093 100644 --- a/homeassistant/components/todo/trigger.py +++ b/homeassistant/components/todo/trigger.py @@ -10,7 +10,7 @@ import voluptuous as vol -from homeassistant.const import ATTR_ENTITY_ID, CONF_TARGET +from homeassistant.const import ATTR_ENTITY_ID, CONF_OPTIONS, CONF_TARGET from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -25,6 +25,7 @@ ITEM_TRIGGER_SCHEMA = vol.Schema( { vol.Required(CONF_TARGET): cv.TARGET_FIELDS, + vol.Required(CONF_OPTIONS, default={}): {}, } ) diff --git a/tests/components/todo/test_trigger.py b/tests/components/todo/test_trigger.py index dabc9f29c37c2f..bbbcdb08686e50 100644 --- a/tests/components/todo/test_trigger.py +++ b/tests/components/todo/test_trigger.py @@ -36,6 +36,10 @@ from . import MockTodoListEntity, create_mock_platform from tests.common import async_mock_service, mock_device_registry +from tests.components.common import ( + assert_trigger_gated_by_labs_flag, + assert_trigger_options_supported, +) TODO_ENTITY_ID1 = "todo.list_one" TODO_ENTITY_ID2 = "todo.list_two" @@ -122,6 +126,47 @@ def service_calls(hass: HomeAssistant) -> list[ServiceCall]: return async_mock_service(hass, "test", "item_added") +@pytest.mark.parametrize( + "trigger_key", + [ + "todo.item_added", + "todo.item_completed", + "todo.item_removed", + ], +) +async def test_todo_triggers_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str +) -> None: + """Test the todo triggers are gated by the labs flag.""" + await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("todo.item_added", None, False, False), + ("todo.item_completed", None, False, False), + ("todo.item_removed", None, False, False), + ], +) +async def test_todo_trigger_options_validation( + hass: HomeAssistant, + trigger_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that todo triggers support the expected options.""" + await assert_trigger_options_supported( + hass, + trigger_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + def _assert_service_calls( service_calls: list[ServiceCall], expected_calls: list[dict[str, Any]] ) -> None: From 886e66e7e3ecb9268bfc244a3d621c072f8978dd Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Wed, 6 May 2026 22:20:16 +0200 Subject: [PATCH 09/11] Bump homematicip to 2.10.0 (#169950) --- homeassistant/components/homematicip_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 1fdb2b8628c9e9..dd6722f0087ab1 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==2.9.0"] + "requirements": ["homematicip==2.10.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2bc5f3cfb0a78b..e8df893f466aaf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1260,7 +1260,7 @@ homekit-audio-proxy==1.2.1 homelink-integration-api==0.0.1 # homeassistant.components.homematicip_cloud -homematicip==2.9.0 +homematicip==2.10.0 # homeassistant.components.homevolt homevolt==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 265dc7804262e1..d5df0f298bfa0e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1124,7 +1124,7 @@ homekit-audio-proxy==1.2.1 homelink-integration-api==0.0.1 # homeassistant.components.homematicip_cloud -homematicip==2.9.0 +homematicip==2.10.0 # homeassistant.components.homevolt homevolt==0.5.0 From c92128b2823957a1056a2c8801db439d486a9b1c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 6 May 2026 22:37:27 +0200 Subject: [PATCH 10/11] Remove advanced setting dependency for IMAP integration (#169827) --- homeassistant/components/imap/config_flow.py | 29 +++++++------------- tests/components/imap/test_config_flow.py | 28 +++++++++---------- tests/components/imap/test_diagnostics.py | 2 ++ 3 files changed, 26 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index e859dcc4ead484..838fa288d26efa 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -76,14 +76,12 @@ vol.Optional(CONF_SEARCH, default="UnSeen UnDeleted"): str, # The default for new entries is to not include text and headers vol.Optional(CONF_EVENT_MESSAGE_DATA, default=[]): EVENT_MESSAGE_DATA_SELECTOR, + vol.Optional( + CONF_SSL_CIPHER_LIST, default=SSLCipherList.PYTHON_DEFAULT + ): CIPHER_SELECTOR, + vol.Optional(CONF_VERIFY_SSL, default=True): BOOLEAN_SELECTOR, } ) -CONFIG_SCHEMA_ADVANCED = { - vol.Optional( - CONF_SSL_CIPHER_LIST, default=SSLCipherList.PYTHON_DEFAULT - ): CIPHER_SELECTOR, - vol.Optional(CONF_VERIFY_SSL, default=True): BOOLEAN_SELECTOR, -} OPTIONS_SCHEMA = vol.Schema( { @@ -93,18 +91,15 @@ vol.Optional( CONF_EVENT_MESSAGE_DATA, default=MESSAGE_DATA_OPTIONS ): EVENT_MESSAGE_DATA_SELECTOR, + vol.Optional(CONF_CUSTOM_EVENT_DATA_TEMPLATE): TEMPLATE_SELECTOR, + vol.Optional(CONF_MAX_MESSAGE_SIZE, default=DEFAULT_MAX_MESSAGE_SIZE): vol.All( + cv.positive_int, + vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT), + ), + vol.Optional(CONF_ENABLE_PUSH, default=True): BOOLEAN_SELECTOR, } ) -OPTIONS_SCHEMA_ADVANCED = { - vol.Optional(CONF_CUSTOM_EVENT_DATA_TEMPLATE): TEMPLATE_SELECTOR, - vol.Optional(CONF_MAX_MESSAGE_SIZE, default=DEFAULT_MAX_MESSAGE_SIZE): vol.All( - cv.positive_int, - vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT), - ), - vol.Optional(CONF_ENABLE_PUSH, default=True): BOOLEAN_SELECTOR, -} - async def validate_input( hass: HomeAssistant, user_input: dict[str, Any] @@ -151,8 +146,6 @@ async def async_step_user( """Handle the initial step.""" schema = CONFIG_SCHEMA - if self.show_advanced_options: - schema = schema.extend(CONFIG_SCHEMA_ADVANCED) if user_input is None: return self.async_show_form(step_id="user", data_schema=schema) @@ -250,8 +243,6 @@ async def async_step_init( return self.async_create_entry(data={}) schema = OPTIONS_SCHEMA - if self.show_advanced_options: - schema = schema.extend(OPTIONS_SCHEMA_ADVANCED) schema = self.add_suggested_values_to_schema(schema, entry_data) return self.async_show_form(step_id="init", data_schema=schema, errors=errors) diff --git a/tests/components/imap/test_config_flow.py b/tests/components/imap/test_config_flow.py index 2270030ad4ffde..884f1ef4ba7f02 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -30,6 +30,8 @@ "folder": "INBOX", "search": "UnSeen UnDeleted", "event_message_data": ["text", "headers"], + "ssl_cipher_list": "python_default", + "verify_ssl": True, } MOCK_OPTIONS = { @@ -301,7 +303,7 @@ async def test_reauth_failed_conn_error(hass: HomeAssistant) -> None: async def test_options_form(hass: HomeAssistant) -> None: - """Test we show the options form.""" + """Test the options form.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) entry.add_to_hass(hass) @@ -381,7 +383,7 @@ async def test_key_options_in_options_form(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("advanced_options", "assert_result"), + ("test_options", "assert_result"), [ ({"max_message_size": 8192}, FlowResultType.CREATE_ENTRY), ({"max_message_size": 1024}, FlowResultType.FORM), @@ -407,12 +409,12 @@ async def test_key_options_in_options_form(hass: HomeAssistant) -> None: "enable_push_false", ], ) -async def test_advanced_options_form( +async def test_options_flow_when_connection_fails( hass: HomeAssistant, - advanced_options: dict[str, str], + test_options: dict[str, str], assert_result: FlowResultType, ) -> None: - """Test we show the advanced options.""" + """Test the options flow when the connection fails.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) entry.add_to_hass(hass) @@ -420,14 +422,14 @@ async def test_advanced_options_form( result = await hass.config_entries.options.async_init( entry.entry_id, - context={"source": config_entries.SOURCE_USER, "show_advanced_options": True}, + context={"source": config_entries.SOURCE_USER}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" new_config = MOCK_OPTIONS.copy() - new_config.update(advanced_options) + new_config.update(test_options) try: with patch( @@ -462,7 +464,7 @@ async def test_config_flow_with_cipherlist_and_ssl_verify( config["verify_ssl"] = verify_ssl result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_USER, "show_advanced_options": True}, + context={"source": config_entries.SOURCE_USER}, ) assert result["type"] is FlowResultType.FORM assert result["errors"] is None @@ -494,7 +496,7 @@ async def test_config_flow_with_event_message_data( config["event_message_data"] = event_message_data result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_USER, "show_advanced_options": False}, + context={"source": config_entries.SOURCE_USER}, ) assert result["type"] is FlowResultType.FORM assert result["errors"] is None @@ -517,16 +519,14 @@ async def test_config_flow_with_event_message_data( assert len(mock_setup_entry.mock_calls) == 1 -async def test_config_flow_from_with_advanced_settings( +async def test_cipher_settings_in_config_flow( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: - """Test if advanced settings show correctly.""" + """Test cipher settings in config flow.""" config = MOCK_CONFIG.copy() - config["ssl_cipher_list"] = "python_default" - config["verify_ssl"] = True result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_USER, "show_advanced_options": True}, + context={"source": config_entries.SOURCE_USER}, ) assert result["type"] is FlowResultType.FORM assert result["errors"] is None diff --git a/tests/components/imap/test_diagnostics.py b/tests/components/imap/test_diagnostics.py index 43f837679c85d3..d06226bb39b4bd 100644 --- a/tests/components/imap/test_diagnostics.py +++ b/tests/components/imap/test_diagnostics.py @@ -72,6 +72,8 @@ async def test_entry_diagnostics( ], "search": "UnSeen UnDeleted", "custom_event_data_template": "{{ 4 * 4 }}", + "ssl_cipher_list": "python_default", + "verify_ssl": True, } expected_event_data = { "date": "2023-03-24T13:52:00+01:00", From 046298f2ca9c496b1655b18732b091bd3cc41439 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 6 May 2026 22:45:36 +0200 Subject: [PATCH 11/11] No need for a local import of the paho mqtt client (#169925) --- homeassistant/components/mqtt/client.py | 26 +++----------------- homeassistant/components/mqtt/config_flow.py | 5 +--- homeassistant/components/mqtt/models.py | 4 +-- tests/common.py | 6 +---- tests/components/mqtt/test_client.py | 26 ++++++-------------- tests/components/mqtt/test_config_flow.py | 8 ++---- tests/components/mqtt/test_init.py | 8 ++---- tests/conftest.py | 4 +-- 8 files changed, 19 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index d114e0055fcd5a..e4cbe843633c1f 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -16,6 +16,8 @@ from uuid import uuid4 import certifi +import paho.mqtt.client as mqtt +from paho.mqtt.matcher import MQTTMatcher from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -47,6 +49,7 @@ from homeassistant.util.collection import chunked_or_all from homeassistant.util.logging import catch_log_exception, log_exception +from .async_client import AsyncMQTTClient from .const import ( CONF_BIRTH_MESSAGE, CONF_BROKER, @@ -86,13 +89,6 @@ ) from .util import EnsureJobAfterCooldown, get_file_path, mqtt_config_entry_enabled -if TYPE_CHECKING: - # Only import for paho-mqtt type checking here, imports are done locally - # because integrations should be able to optionally rely on MQTT. - import paho.mqtt.client as mqtt - - from .async_client import AsyncMQTTClient - _LOGGER = logging.getLogger(__name__) MIN_BUFFER_SIZE = 131072 # Minimum buffer size to use if preferred size fails @@ -323,12 +319,6 @@ def setup(self) -> None: The setup of the MQTT client should be run in an executor job, because it accesses files, so it does IO. """ - # We don't import on the top because some integrations - # should be able to optionally rely on MQTT. - from paho.mqtt import client as mqtt # noqa: PLC0415 - - from .async_client import AsyncMQTTClient # noqa: PLC0415 - config = self._config clean_session: bool | None = None # If no protocol setting is set in the config entry data @@ -561,7 +551,6 @@ def _async_start_misc_periodic(self) -> None: """Start the misc periodic.""" assert self._misc_timer is None, "Misc periodic already started" _LOGGER.debug("%s: Starting client misc loop", self.config_entry.title) - import paho.mqtt.client as mqtt # noqa: PLC0415 # Inner function to avoid having to check late import # each time the function is called. @@ -705,7 +694,6 @@ async def async_publish( async def async_connect(self, client_available: asyncio.Future[bool]) -> None: """Connect to the host. Does not process messages yet.""" - import paho.mqtt.client as mqtt # noqa: PLC0415 result: int | None = None self._available_future = client_available @@ -763,7 +751,6 @@ def _async_cancel_reconnect(self) -> None: async def _reconnect_loop(self) -> None: """Reconnect to the MQTT server.""" - import paho.mqtt.client as mqtt # noqa: PLC0415 while True: if not self.connected: @@ -1265,9 +1252,6 @@ def _async_get_mid_future(self, mid: int) -> asyncio.Future[None]: @callback def _async_handle_callback_exception(self, status: mqtt.MQTTErrorCode) -> None: """Handle a callback exception.""" - # We don't import on the top because some integrations - # should be able to optionally rely on MQTT. - import paho.mqtt.client as mqtt # noqa: PLC0415 _LOGGER.warning( "Error returned from MQTT server: %s", @@ -1312,8 +1296,6 @@ async def _async_wait_for_mid_or_raise( ) -> None: """Wait for ACK from broker or raise on error.""" if result_code != 0: - import paho.mqtt.client as mqtt # noqa: PLC0415 - raise HomeAssistantError( translation_domain=DOMAIN, translation_key="mqtt_broker_error", @@ -1360,8 +1342,6 @@ async def _discovery_cooldown(self) -> None: def _matcher_for_topic(subscription: str) -> Callable[[str], bool]: - from paho.mqtt.matcher import MQTTMatcher # noqa: PLC0415 - matcher = MQTTMatcher() # type: ignore[no-untyped-call] matcher[subscription] = True diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 08d9af23be8f8c..80529b2487bd22 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -22,6 +22,7 @@ load_pem_private_key, ) from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate +import paho.mqtt.client as mqtt import voluptuous as vol import yaml @@ -5479,10 +5480,6 @@ def try_connection( user_input: dict[str, Any], ) -> bool: """Test if we can connect to an MQTT broker.""" - # We don't import on the top because some integrations - # should be able to optionally rely on MQTT. - import paho.mqtt.client as mqtt # noqa: PLC0415 - mqtt_client_setup = MqttClientSetup(user_input) mqtt_client_setup.setup() client = mqtt_client_setup.client diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 093db34498d713..29bcb208979377 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -9,6 +9,8 @@ import logging from typing import TYPE_CHECKING, Any, TypedDict +from paho.mqtt.client import MQTTMessage + from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME, Platform from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.exceptions import ServiceValidationError, TemplateError @@ -24,8 +26,6 @@ from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: - from paho.mqtt.client import MQTTMessage - from .client import MQTT, Subscription from .debug_info import TimestampedPublishMessage from .device_trigger import Trigger diff --git a/tests/common.py b/tests/common.py index e12e81618fc861..79149433599a42 100644 --- a/tests/common.py +++ b/tests/common.py @@ -29,6 +29,7 @@ from aiohttp.test_utils import unused_port as get_test_instance_port from annotatedyaml import load_yaml_dict, loader as yaml_loader import attr +from paho.mqtt.client import MQTTMessage import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol @@ -453,11 +454,6 @@ def async_fire_mqtt_message( retain: bool = False, ) -> None: """Fire the MQTT message.""" - # Local import to avoid processing MQTT modules when running a testcase - # which does not use MQTT. - - from paho.mqtt.client import MQTTMessage # noqa: PLC0415 - from homeassistant.components.mqtt import MqttData # noqa: PLC0415 if isinstance(payload, str): diff --git a/tests/components/mqtt/test_client.py b/tests/components/mqtt/test_client.py index d9111bba564312..c1880b6918bfd3 100644 --- a/tests/components/mqtt/test_client.py +++ b/tests/components/mqtt/test_client.py @@ -88,9 +88,7 @@ class FakeInfo: mid = 100 rc = 0 - with patch( - "homeassistant.components.mqtt.async_client.AsyncMQTTClient" - ) as mock_client: + with patch("homeassistant.components.mqtt.client.AsyncMQTTClient") as mock_client: mqtt_client = mock_client.return_value mqtt_client.connect = MagicMock( return_value=0, @@ -1305,9 +1303,7 @@ async def test_publish_error( entry.add_to_hass(hass) # simulate an Out of memory error - with patch( - "homeassistant.components.mqtt.async_client.AsyncMQTTClient" - ) as mock_client: + with patch("homeassistant.components.mqtt.client.AsyncMQTTClient") as mock_client: mock_client().connect = lambda **kwargs: 1 mock_client().publish().rc = 1 assert await hass.config_entries.async_setup(entry.entry_id) @@ -1404,9 +1400,7 @@ async def test_setup_mqtt_client_clean_session_and_protocol( clean_session: bool | None, ) -> None: """Test MQTT client clean_session and protocol setup.""" - with patch( - "homeassistant.components.mqtt.async_client.AsyncMQTTClient" - ) as mock_client: + with patch("homeassistant.components.mqtt.client.AsyncMQTTClient") as mock_client: await mqtt_mock_entry() # check if clean_session was correctly @@ -1470,9 +1464,7 @@ class FakeInfo: mid = 102 rc = 0 - with patch( - "homeassistant.components.mqtt.async_client.AsyncMQTTClient" - ) as mock_client: + with patch("homeassistant.components.mqtt.client.AsyncMQTTClient") as mock_client: def _mock_ack(topic: str, qos: int = 0) -> tuple[int, int]: # Handle ACK for subscribe normally @@ -1539,9 +1531,7 @@ async def test_setup_raises_config_entry_not_ready_if_no_connect_broker( ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.mqtt.async_client.AsyncMQTTClient" - ) as mock_client: + with patch("homeassistant.components.mqtt.client.AsyncMQTTClient") as mock_client: mock_client().connect = MagicMock(side_effect=exception) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -1576,9 +1566,7 @@ def mock_tls_set( def mock_tls_insecure_set(insecure_param) -> None: insecure_check["insecure"] = insecure_param - with patch( - "homeassistant.components.mqtt.async_client.AsyncMQTTClient" - ) as mock_client: + with patch("homeassistant.components.mqtt.client.AsyncMQTTClient") as mock_client: mock_client().tls_set = mock_tls_set mock_client().tls_insecure_set = mock_tls_insecure_set await mqtt_mock_entry() @@ -1618,7 +1606,7 @@ async def test_client_id_is_set( ) -> None: """Test setup defaults for tls.""" with patch( - "homeassistant.components.mqtt.async_client.AsyncMQTTClient" + "homeassistant.components.mqtt.client.AsyncMQTTClient" ) as async_client_mock: await mqtt_mock_entry() await hass.async_block_till_done() diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index c1b8b25c5153e7..b01b4fd1513181 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -254,9 +254,7 @@ def _unsubscribe(topic): mock_client().on_unsubscribe(mock_client, 0, mid, [MockMqttReasonCode()], None) return (0, mid) - with patch( - "homeassistant.components.mqtt.async_client.AsyncMQTTClient" - ) as mock_client: + with patch("homeassistant.components.mqtt.client.AsyncMQTTClient") as mock_client: mock_client().loop_start = loop_start mock_client().subscribe = _subscribe mock_client().unsubscribe = _unsubscribe @@ -270,9 +268,7 @@ def mock_try_connection_time_out() -> Generator[MagicMock]: # Patch prevent waiting 5 sec for a timeout with ( - patch( - "homeassistant.components.mqtt.async_client.AsyncMQTTClient" - ) as mock_client, + patch("homeassistant.components.mqtt.client.AsyncMQTTClient") as mock_client, patch("homeassistant.components.mqtt.config_flow.MQTT_TIMEOUT", 0), ): mock_client().loop_start = lambda *args: 1 diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 93ff1d4a9555bd..69440fd1717544 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -10,6 +10,7 @@ from unittest.mock import ANY, MagicMock, Mock, mock_open, patch from freezegun.api import FrozenDateTimeFactory +from paho.mqtt.client import MQTTMessage import pytest import voluptuous as vol @@ -700,11 +701,6 @@ async def test_receiving_message_with_non_utf8_topic_gets_logged( await mqtt_mock_entry() await mqtt.async_subscribe(hass, "test-topic", record_calls) - # Local import to avoid processing MQTT modules when running a testcase - # which does not use MQTT. - - from paho.mqtt.client import MQTTMessage # noqa: PLC0415 - from homeassistant.components.mqtt.models import MqttData # noqa: PLC0415 msg = MQTTMessage(topic=b"tasmota/discovery/18FE34E0B760\xcc\x02") @@ -1910,7 +1906,7 @@ def _check_entities() -> int: assert _check_entities() == 2 # reload entry and assert again - with patch("homeassistant.components.mqtt.async_client.AsyncMQTTClient"): + with patch("homeassistant.components.mqtt.client.AsyncMQTTClient"): await hass.config_entries.async_reload(mqtt_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/conftest.py b/tests/conftest.py index 313fccbb730e78..61e6ca474d0ba7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1057,9 +1057,7 @@ def __init__(self, mid: int) -> None: self.mid = mid self.rc = 0 - with patch( - "homeassistant.components.mqtt.async_client.AsyncMQTTClient" - ) as mock_client: + with patch("homeassistant.components.mqtt.client.AsyncMQTTClient") as mock_client: # The below use a call_soon for the on_publish/on_subscribe/on_unsubscribe # callbacks to simulate the behavior of the real MQTT client which will # not be synchronous.