From 3040fa341268ce61046a203f011d8c98308644ff Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 15 Jan 2026 16:46:55 +0100 Subject: [PATCH 1/5] Require admin for blueprint ws commands (#161008) --- .../components/blueprint/websocket_api.py | 5 ++ .../blueprint/test_websocket_api.py | 46 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/homeassistant/components/blueprint/websocket_api.py b/homeassistant/components/blueprint/websocket_api.py index 0743d027d8dd7d..873e3b30a364ae 100644 --- a/homeassistant/components/blueprint/websocket_api.py +++ b/homeassistant/components/blueprint/websocket_api.py @@ -64,6 +64,7 @@ async def with_domain_blueprints( return with_domain_blueprints +@websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "blueprint/list", @@ -97,6 +98,7 @@ async def ws_list_blueprints( connection.send_result(msg["id"], results) +@websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "blueprint/import", @@ -150,6 +152,7 @@ async def ws_import_blueprint( ) +@websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "blueprint/save", @@ -206,6 +209,7 @@ async def ws_save_blueprint( ) +@websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "blueprint/delete", @@ -233,6 +237,7 @@ async def ws_delete_blueprint( ) +@websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "blueprint/substitute", diff --git a/tests/components/blueprint/test_websocket_api.py b/tests/components/blueprint/test_websocket_api.py index 8374054ca95843..96a9323fda5c73 100644 --- a/tests/components/blueprint/test_websocket_api.py +++ b/tests/components/blueprint/test_websocket_api.py @@ -11,6 +11,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util.yaml import UndefinedSubstitution, parse_yaml +from tests.common import MockUser from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator @@ -103,6 +104,51 @@ async def test_list_blueprints_non_existing_domain( assert blueprints == {} +@pytest.mark.parametrize( + "message", + [ + {"type": "blueprint/list", "domain": "automation"}, + {"type": "blueprint/import", "url": "https://example.com/blueprint.yaml"}, + { + "type": "blueprint/save", + "path": "test_save", + "yaml": "raw_data", + "domain": "automation", + }, + { + "type": "blueprint/delete", + "path": "test_delete", + "domain": "automation", + }, + { + "type": "blueprint/substitute", + "domain": "automation", + "path": "test_event_service.yaml", + "input": { + "trigger_event": "test_event", + "service_to_call": "test.automation", + "a_number": 5, + }, + }, + ], +) +async def test_blueprint_ws_command_requires_admin( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_admin_user: MockUser, + message: dict[str, Any], +) -> None: + """Test that blueprint websocket commands require admin.""" + hass_admin_user.groups = [] # Remove admin privileges + client = await hass_ws_client(hass) + await client.send_json_auto_id(message) + + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "unauthorized" + + async def test_import_blueprint( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, From 457af066c812ab073d288a6318a0048bc2720740 Mon Sep 17 00:00:00 2001 From: Jaap Pieroen Date: Thu, 15 Jan 2026 19:42:18 +0100 Subject: [PATCH 2/5] Decrease Essent update interval to 1 hour (#160959) --- homeassistant/components/essent/const.py | 2 +- tests/components/essent/test_sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/essent/const.py b/homeassistant/components/essent/const.py index 6b4167925c0b10..4b505e21136876 100644 --- a/homeassistant/components/essent/const.py +++ b/homeassistant/components/essent/const.py @@ -7,7 +7,7 @@ from typing import Final DOMAIN: Final = "essent" -UPDATE_INTERVAL: Final = timedelta(hours=12) +UPDATE_INTERVAL: Final = timedelta(hours=1) ATTRIBUTION: Final = "Data provided by Essent" diff --git a/tests/components/essent/test_sensor.py b/tests/components/essent/test_sensor.py index ce20518b527e9d..db3c4c4cce2dfb 100644 --- a/tests/components/essent/test_sensor.py +++ b/tests/components/essent/test_sensor.py @@ -74,5 +74,5 @@ async def test_sensor_updates_on_hour_tick( assert ( hass.states.get("sensor.essent_current_electricity_market_price").state - == "0.10417" + == "0.24535" ) From 043a0b5aa693e313efee29dd3258d188d0f235f6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 15 Jan 2026 20:17:02 +0100 Subject: [PATCH 3/5] Add alarm_control_panel conditions (#160975) --- .../alarm_control_panel/condition.py | 93 ++++++ .../alarm_control_panel/conditions.yaml | 52 ++++ .../components/alarm_control_panel/icons.json | 23 ++ .../alarm_control_panel/strings.json | 80 +++++ .../components/automation/__init__.py | 1 + tests/components/__init__.py | 18 +- .../alarm_control_panel/test_condition.py | 275 ++++++++++++++++++ 7 files changed, 537 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/alarm_control_panel/condition.py create mode 100644 homeassistant/components/alarm_control_panel/conditions.yaml create mode 100644 tests/components/alarm_control_panel/test_condition.py diff --git a/homeassistant/components/alarm_control_panel/condition.py b/homeassistant/components/alarm_control_panel/condition.py new file mode 100644 index 00000000000000..b1d3da3488b6e2 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/condition.py @@ -0,0 +1,93 @@ +"""Provides conditions for alarm control panels.""" + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.condition import ( + Condition, + EntityStateConditionBase, + make_entity_state_condition, +) +from homeassistant.helpers.entity import get_supported_features + +from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelState + + +def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool: + """Test if an entity supports the specified features.""" + try: + return bool(get_supported_features(hass, entity_id) & features) + except HomeAssistantError: + return False + + +class EntityStateRequiredFeaturesCondition(EntityStateConditionBase): + """State condition.""" + + _required_features: int + + def entity_filter(self, entities: set[str]) -> set[str]: + """Filter entities of this domain with the required features.""" + entities = super().entity_filter(entities) + return { + entity_id + for entity_id in entities + if supports_feature(self._hass, entity_id, self._required_features) + } + + +def make_entity_state_required_features_condition( + domain: str, to_state: str, required_features: int +) -> type[EntityStateRequiredFeaturesCondition]: + """Create an entity state condition class with required feature filtering.""" + + class CustomCondition(EntityStateRequiredFeaturesCondition): + """Condition for entity state changes.""" + + _domain = domain + _states = {to_state} + _required_features = required_features + + return CustomCondition + + +CONDITIONS: dict[str, type[Condition]] = { + "is_armed": make_entity_state_condition( + DOMAIN, + { + AlarmControlPanelState.ARMED_AWAY, + AlarmControlPanelState.ARMED_CUSTOM_BYPASS, + AlarmControlPanelState.ARMED_HOME, + AlarmControlPanelState.ARMED_NIGHT, + AlarmControlPanelState.ARMED_VACATION, + }, + ), + "is_armed_away": make_entity_state_required_features_condition( + DOMAIN, + AlarmControlPanelState.ARMED_AWAY, + AlarmControlPanelEntityFeature.ARM_AWAY, + ), + "is_armed_home": make_entity_state_required_features_condition( + DOMAIN, + AlarmControlPanelState.ARMED_HOME, + AlarmControlPanelEntityFeature.ARM_HOME, + ), + "is_armed_night": make_entity_state_required_features_condition( + DOMAIN, + AlarmControlPanelState.ARMED_NIGHT, + AlarmControlPanelEntityFeature.ARM_NIGHT, + ), + "is_armed_vacation": make_entity_state_required_features_condition( + DOMAIN, + AlarmControlPanelState.ARMED_VACATION, + AlarmControlPanelEntityFeature.ARM_VACATION, + ), + "is_disarmed": make_entity_state_condition(DOMAIN, AlarmControlPanelState.DISARMED), + "is_triggered": make_entity_state_condition( + DOMAIN, AlarmControlPanelState.TRIGGERED + ), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the alarm control panel conditions.""" + return CONDITIONS diff --git a/homeassistant/components/alarm_control_panel/conditions.yaml b/homeassistant/components/alarm_control_panel/conditions.yaml new file mode 100644 index 00000000000000..12c5b700b3276f --- /dev/null +++ b/homeassistant/components/alarm_control_panel/conditions.yaml @@ -0,0 +1,52 @@ +.condition_common: &condition_common + target: + entity: + domain: alarm_control_panel + fields: &condition_common_fields + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + +is_armed: *condition_common + +is_armed_away: + fields: *condition_common_fields + target: + entity: + domain: alarm_control_panel + supported_features: + - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_AWAY + +is_armed_home: + fields: *condition_common_fields + target: + entity: + domain: alarm_control_panel + supported_features: + - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME + +is_armed_night: + fields: *condition_common_fields + target: + entity: + domain: alarm_control_panel + supported_features: + - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_NIGHT + +is_armed_vacation: + fields: *condition_common_fields + target: + entity: + domain: alarm_control_panel + supported_features: + - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_VACATION + +is_disarmed: *condition_common + +is_triggered: *condition_common diff --git a/homeassistant/components/alarm_control_panel/icons.json b/homeassistant/components/alarm_control_panel/icons.json index 0fbaeb59cf5b09..b7db7d4fffdc74 100644 --- a/homeassistant/components/alarm_control_panel/icons.json +++ b/homeassistant/components/alarm_control_panel/icons.json @@ -1,4 +1,27 @@ { + "conditions": { + "is_armed": { + "condition": "mdi:shield" + }, + "is_armed_away": { + "condition": "mdi:shield-lock" + }, + "is_armed_home": { + "condition": "mdi:shield-home" + }, + "is_armed_night": { + "condition": "mdi:shield-moon" + }, + "is_armed_vacation": { + "condition": "mdi:shield-airplane" + }, + "is_disarmed": { + "condition": "mdi:shield-off" + }, + "is_triggered": { + "condition": "mdi:bell-ring" + } + }, "entity_component": { "_": { "default": "mdi:shield", diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index 94567d69f3e70e..88d203193c3f03 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -1,8 +1,82 @@ { "common": { + "condition_behavior_description": "How the state should match on the targeted alarms.", + "condition_behavior_name": "Behavior", "trigger_behavior_description": "The behavior of the targeted alarms to trigger on.", "trigger_behavior_name": "Behavior" }, + "conditions": { + "is_armed": { + "description": "Tests if one or more alarms are armed.", + "fields": { + "behavior": { + "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", + "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" + } + }, + "name": "If an alarm is armed" + }, + "is_armed_away": { + "description": "Tests if one or more alarms are armed in away mode.", + "fields": { + "behavior": { + "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", + "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" + } + }, + "name": "If an alarm is armed away" + }, + "is_armed_home": { + "description": "Tests if one or more alarms are armed in home mode.", + "fields": { + "behavior": { + "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", + "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" + } + }, + "name": "If an alarm is armed home" + }, + "is_armed_night": { + "description": "Tests if one or more alarms are armed in night mode.", + "fields": { + "behavior": { + "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", + "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" + } + }, + "name": "If an alarm is armed night" + }, + "is_armed_vacation": { + "description": "Tests if one or more alarms are armed in vacation mode.", + "fields": { + "behavior": { + "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", + "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" + } + }, + "name": "If an alarm is armed vacation" + }, + "is_disarmed": { + "description": "Tests if one or more alarms are disarmed.", + "fields": { + "behavior": { + "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", + "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" + } + }, + "name": "If an alarm is disarmed" + }, + "is_triggered": { + "description": "Tests if one or more alarms are triggered.", + "fields": { + "behavior": { + "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", + "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" + } + }, + "name": "If an alarm is triggered" + } + }, "device_automation": { "action_type": { "arm_away": "Arm {entity_name} away", @@ -76,6 +150,12 @@ } }, "selector": { + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + }, "trigger_behavior": { "options": { "any": "Any", diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index b173fa5b248dc2..34097bb989ba68 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -123,6 +123,7 @@ NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG = "new_triggers_conditions" _EXPERIMENTAL_CONDITION_PLATFORMS = { + "alarm_control_panel", "fan", "light", } diff --git a/tests/components/__init__.py b/tests/components/__init__.py index c1181074250549..ff77e72276450c 100644 --- a/tests/components/__init__.py +++ b/tests/components/__init__.py @@ -254,13 +254,21 @@ def state_with_attributes( state_with_attributes(other_state, False, True) for other_state in other_states ), - ( - state_with_attributes(target_state, True, True) - for target_state in target_states - ), - ) + ), ), ), + # Test each target state individually to isolate condition_true expectations + *( + ( + condition, + condition_options, + [ + state_with_attributes(other_states[0], False, True), + state_with_attributes(target_state, True, True), + ], + ) + for target_state in target_states + ), ] diff --git a/tests/components/alarm_control_panel/test_condition.py b/tests/components/alarm_control_panel/test_condition.py new file mode 100644 index 00000000000000..6592da34e73e68 --- /dev/null +++ b/tests/components/alarm_control_panel/test_condition.py @@ -0,0 +1,275 @@ +"""Test alarm_control_panel conditions.""" + +from typing import Any + +import pytest + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntityFeature, + AlarmControlPanelState, +) +from homeassistant.const import ATTR_SUPPORTED_FEATURES +from homeassistant.core import HomeAssistant + +from tests.components import ( + ConditionStateDescription, + assert_condition_gated_by_labs_flag, + create_target_condition, + other_states, + parametrize_condition_states, + parametrize_target_entities, + set_or_remove_state, + target_entities, +) + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + + +@pytest.fixture +async def target_alarm_control_panels(hass: HomeAssistant) -> list[str]: + """Create multiple alarm_control_panel entities associated with different targets.""" + return (await target_entities(hass, "alarm_control_panel"))["included"] + + +@pytest.mark.parametrize( + "condition", + [ + "alarm_control_panel.is_armed", + "alarm_control_panel.is_armed_away", + "alarm_control_panel.is_armed_home", + "alarm_control_panel.is_armed_night", + "alarm_control_panel.is_armed_vacation", + "alarm_control_panel.is_disarmed", + "alarm_control_panel.is_triggered", + ], +) +async def test_alarm_control_panel_conditions_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str +) -> None: + """Test the alarm_control_panel conditions are gated by the labs flag.""" + await assert_condition_gated_by_labs_flag(hass, caplog, condition) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("alarm_control_panel"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states( + condition="alarm_control_panel.is_armed", + target_states=[ + AlarmControlPanelState.ARMED_AWAY, + AlarmControlPanelState.ARMED_CUSTOM_BYPASS, + AlarmControlPanelState.ARMED_HOME, + AlarmControlPanelState.ARMED_NIGHT, + AlarmControlPanelState.ARMED_VACATION, + ], + other_states=[ + AlarmControlPanelState.ARMING, + AlarmControlPanelState.DISARMED, + AlarmControlPanelState.DISARMING, + AlarmControlPanelState.PENDING, + AlarmControlPanelState.TRIGGERED, + ], + ), + *parametrize_condition_states( + condition="alarm_control_panel.is_armed_away", + target_states=[AlarmControlPanelState.ARMED_AWAY], + other_states=other_states(AlarmControlPanelState.ARMED_AWAY), + additional_attributes={ + ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_AWAY + }, + ), + *parametrize_condition_states( + condition="alarm_control_panel.is_armed_home", + target_states=[AlarmControlPanelState.ARMED_HOME], + other_states=other_states(AlarmControlPanelState.ARMED_HOME), + additional_attributes={ + ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_HOME + }, + ), + *parametrize_condition_states( + condition="alarm_control_panel.is_armed_night", + target_states=[AlarmControlPanelState.ARMED_NIGHT], + other_states=other_states(AlarmControlPanelState.ARMED_NIGHT), + additional_attributes={ + ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_NIGHT + }, + ), + *parametrize_condition_states( + condition="alarm_control_panel.is_armed_vacation", + target_states=[AlarmControlPanelState.ARMED_VACATION], + other_states=other_states(AlarmControlPanelState.ARMED_VACATION), + additional_attributes={ + ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_VACATION + }, + ), + *parametrize_condition_states( + condition="alarm_control_panel.is_disarmed", + target_states=[AlarmControlPanelState.DISARMED], + other_states=other_states(AlarmControlPanelState.DISARMED), + ), + *parametrize_condition_states( + condition="alarm_control_panel.is_triggered", + target_states=[AlarmControlPanelState.TRIGGERED], + other_states=other_states(AlarmControlPanelState.TRIGGERED), + ), + ], +) +async def test_alarm_control_panel_state_condition_behavior_any( + hass: HomeAssistant, + target_alarm_control_panels: list[str], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the alarm_control_panel state condition with the 'any' behavior.""" + other_entity_ids = set(target_alarm_control_panels) - {entity_id} + + # Set all alarm_control_panels, including the tested alarm_control_panel, to the initial state + for eid in target_alarm_control_panels: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + condition = await create_target_condition( + hass, + condition=condition, + target=condition_target_config, + behavior="any", + ) + + for state in states: + included_state = state["included"] + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert condition(hass) == state["condition_true"] + + # Check if changing other alarm_control_panels also passes the condition + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + assert condition(hass) == state["condition_true"] + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("alarm_control_panel"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states( + condition="alarm_control_panel.is_armed", + target_states=[ + AlarmControlPanelState.ARMED_AWAY, + AlarmControlPanelState.ARMED_CUSTOM_BYPASS, + AlarmControlPanelState.ARMED_HOME, + AlarmControlPanelState.ARMED_NIGHT, + AlarmControlPanelState.ARMED_VACATION, + ], + other_states=[ + AlarmControlPanelState.ARMING, + AlarmControlPanelState.DISARMED, + AlarmControlPanelState.DISARMING, + AlarmControlPanelState.PENDING, + AlarmControlPanelState.TRIGGERED, + ], + ), + *parametrize_condition_states( + condition="alarm_control_panel.is_armed_away", + target_states=[AlarmControlPanelState.ARMED_AWAY], + other_states=other_states(AlarmControlPanelState.ARMED_AWAY), + additional_attributes={ + ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_AWAY + }, + ), + *parametrize_condition_states( + condition="alarm_control_panel.is_armed_home", + target_states=[AlarmControlPanelState.ARMED_HOME], + other_states=other_states(AlarmControlPanelState.ARMED_HOME), + additional_attributes={ + ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_HOME + }, + ), + *parametrize_condition_states( + condition="alarm_control_panel.is_armed_night", + target_states=[AlarmControlPanelState.ARMED_NIGHT], + other_states=other_states(AlarmControlPanelState.ARMED_NIGHT), + additional_attributes={ + ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_NIGHT + }, + ), + *parametrize_condition_states( + condition="alarm_control_panel.is_armed_vacation", + target_states=[AlarmControlPanelState.ARMED_VACATION], + other_states=other_states(AlarmControlPanelState.ARMED_VACATION), + additional_attributes={ + ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_VACATION + }, + ), + *parametrize_condition_states( + condition="alarm_control_panel.is_disarmed", + target_states=[AlarmControlPanelState.DISARMED], + other_states=other_states(AlarmControlPanelState.DISARMED), + ), + *parametrize_condition_states( + condition="alarm_control_panel.is_triggered", + target_states=[AlarmControlPanelState.TRIGGERED], + other_states=other_states(AlarmControlPanelState.TRIGGERED), + ), + ], +) +async def test_alarm_control_panel_state_condition_behavior_all( + hass: HomeAssistant, + target_alarm_control_panels: list[str], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the alarm_control_panel state condition with the 'all' behavior.""" + other_entity_ids = set(target_alarm_control_panels) - {entity_id} + + # Set all alarm_control_panels, including the tested alarm_control_panel, to the initial state + for eid in target_alarm_control_panels: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + condition = await create_target_condition( + hass, + condition=condition, + target=condition_target_config, + behavior="all", + ) + + for state in states: + included_state = state["included"] + + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + # The condition passes if all entities are either in a target state or invalid + assert condition(hass) == ( + (not state["state_valid"]) + or (state["condition_true"] and entities_in_target == 1) + ) + + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + + # The condition passes if all entities are either in a target state or invalid + assert condition(hass) == ( + (not state["state_valid"]) or state["condition_true"] + ) From e2e7485e3049820f4338748d81f41054724ef267 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 15 Jan 2026 22:03:18 +0100 Subject: [PATCH 4/5] Remove unused test fixture from light condition tests (#160925) --- tests/components/light/test_condition.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/components/light/test_condition.py b/tests/components/light/test_condition.py index 42863f771790e9..2b88d2bac7ce65 100644 --- a/tests/components/light/test_condition.py +++ b/tests/components/light/test_condition.py @@ -5,7 +5,7 @@ import pytest from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components import ( ConditionStateDescription, @@ -137,7 +137,6 @@ async def test_light_state_condition_behavior_any( ) async def test_light_state_condition_behavior_all( hass: HomeAssistant, - service_calls: list[ServiceCall], target_lights: list[str], condition_target_config: dict, entity_id: str, From 67e676df4f9013d6772b25cfcf3fe6aec6a9155b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 15 Jan 2026 22:12:24 +0100 Subject: [PATCH 5/5] Fix duplicate HVACMode in Tuya climate (#160918) --- homeassistant/components/tuya/climate.py | 29 +++++++--- .../tuya/snapshots/test_climate.ambr | 55 +++++++++++-------- 2 files changed, 53 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 83b37b35ba3bed..ddfd874c9cb044 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -2,6 +2,7 @@ from __future__ import annotations +import collections from dataclasses import dataclass from typing import Any, Self @@ -140,6 +141,22 @@ def get_update_commands( return commands +def _filter_hvac_mode_mappings(tuya_range: list[str]) -> dict[str, HVACMode | None]: + """Filter TUYA_HVAC_TO_HA modes that are not in the range. + + If multiple Tuya modes map to the same HA mode, set the mapping to None to avoid + ambiguity when converting back from HA to Tuya modes. + """ + modes_in_range = { + tuya_mode: TUYA_HVAC_TO_HA.get(tuya_mode) for tuya_mode in tuya_range + } + modes_occurrences = collections.Counter(modes_in_range.values()) + for key, value in modes_in_range.items(): + if value is not None and modes_occurrences[value] > 1: + modes_in_range[key] = None + return modes_in_range + + class _HvacModeWrapper(DPCodeEnumWrapper): """Wrapper for managing climate HVACMode.""" @@ -148,10 +165,9 @@ class _HvacModeWrapper(DPCodeEnumWrapper): def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None: """Init _HvacModeWrapper.""" super().__init__(dpcode, type_information) + self._mappings = _filter_hvac_mode_mappings(type_information.range) self.options = [ - TUYA_HVAC_TO_HA[tuya_mode] - for tuya_mode in type_information.range - if tuya_mode in TUYA_HVAC_TO_HA + ha_mode for ha_mode in self._mappings.values() if ha_mode is not None ] def read_device_status(self, device: CustomerDevice) -> HVACMode | None: @@ -166,7 +182,7 @@ def _convert_value_to_raw_value( """Convert value to raw value.""" return next( tuya_mode - for tuya_mode, ha_mode in TUYA_HVAC_TO_HA.items() + for tuya_mode, ha_mode in self._mappings.items() if ha_mode == value ) @@ -179,10 +195,9 @@ class _PresetWrapper(DPCodeEnumWrapper): def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None: """Init _PresetWrapper.""" super().__init__(dpcode, type_information) + mappings = _filter_hvac_mode_mappings(type_information.range) self.options = [ - tuya_mode - for tuya_mode in type_information.range - if tuya_mode not in TUYA_HVAC_TO_HA + tuya_mode for tuya_mode, ha_mode in mappings.items() if ha_mode is None ] def read_device_status(self, device: CustomerDevice) -> str | None: diff --git a/tests/components/tuya/snapshots/test_climate.ambr b/tests/components/tuya/snapshots/test_climate.ambr index 2cc5450c7ea79a..209acb5d48ba09 100644 --- a/tests/components/tuya/snapshots/test_climate.ambr +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -83,13 +83,13 @@ 'capabilities': dict({ 'hvac_modes': list([ , - , - , , ]), 'max_temp': 35.0, 'min_temp': 5.0, 'preset_modes': list([ + 'auto', + 'manual', 'off', ]), 'target_temp_step': 1.0, @@ -130,13 +130,13 @@ 'friendly_name': 'Anbau', 'hvac_modes': list([ , - , - , , ]), 'max_temp': 35.0, 'min_temp': 5.0, 'preset_modes': list([ + 'auto', + 'manual', 'off', ]), 'supported_features': , @@ -159,13 +159,13 @@ 'hvac_modes': list([ , , - , - , ]), 'max_temp': 70.0, 'min_temp': 1.0, 'preset_modes': list([ 'holiday', + 'auto', + 'manual', 'eco', ]), 'target_temp_step': 0.5, @@ -208,14 +208,14 @@ 'hvac_modes': list([ , , - , - , ]), 'max_temp': 70.0, 'min_temp': 1.0, 'preset_mode': None, 'preset_modes': list([ 'holiday', + 'auto', + 'manual', 'eco', ]), 'supported_features': , @@ -453,13 +453,13 @@ 'capabilities': dict({ 'hvac_modes': list([ , - , - , , ]), 'max_temp': 35.0, 'min_temp': 5.0, 'preset_modes': list([ + 'auto', + 'manual', 'off', ]), 'target_temp_step': 1.0, @@ -501,14 +501,14 @@ 'friendly_name': 'Empore', 'hvac_modes': list([ , - , - , , ]), 'max_temp': 35.0, 'min_temp': 5.0, 'preset_mode': None, 'preset_modes': list([ + 'auto', + 'manual', 'off', ]), 'supported_features': , @@ -532,12 +532,12 @@ 'hvac_modes': list([ , , - , - , ]), 'max_temp': 35.0, 'min_temp': 5.0, 'preset_modes': list([ + 'auto', + 'manual', 'off', ]), 'target_temp_step': 1.0, @@ -580,13 +580,13 @@ 'hvac_modes': list([ , , - , - , ]), 'max_temp': 35.0, 'min_temp': 5.0, 'preset_mode': None, 'preset_modes': list([ + 'auto', + 'manual', 'off', ]), 'supported_features': , @@ -1107,12 +1107,12 @@ 'hvac_modes': list([ , , - , - , ]), 'max_temp': 5.9, 'min_temp': 0.1, 'preset_modes': list([ + 'auto', + 'manual', 'holiday', ]), 'target_temp_step': 0.5, @@ -1155,13 +1155,13 @@ 'hvac_modes': list([ , , - , - , ]), 'max_temp': 5.9, 'min_temp': 0.1, 'preset_mode': None, 'preset_modes': list([ + 'auto', + 'manual', 'holiday', ]), 'supported_features': , @@ -1185,10 +1185,13 @@ 'hvac_modes': list([ , , - , ]), 'max_temp': 90.0, 'min_temp': 5.0, + 'preset_modes': list([ + 'auto', + 'manual', + ]), 'target_temp_step': 1.0, }), 'config_entry_id': , @@ -1215,7 +1218,7 @@ 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'tuya.sb3zdertrw50bgogkw', 'unit_of_measurement': None, @@ -1229,11 +1232,15 @@ 'hvac_modes': list([ , , - , ]), 'max_temp': 90.0, 'min_temp': 5.0, - 'supported_features': , + 'preset_mode': None, + 'preset_modes': list([ + 'auto', + 'manual', + ]), + 'supported_features': , 'target_temp_step': 1.0, 'temperature': 12.0, }),