From cebe4aa685f3a9fe90ff0c8981c89d22832534fc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 24 Apr 2026 07:46:34 +0200 Subject: [PATCH 01/14] Refactor condition API (#168815) Co-authored-by: Artur Pragacz --- .../components/device_automation/condition.py | 17 +- homeassistant/components/sun/condition.py | 28 +-- homeassistant/components/zone/condition.py | 64 +++--- homeassistant/helpers/condition.py | 217 ++++++++++-------- tests/helpers/test_condition.py | 67 +++++- 5 files changed, 230 insertions(+), 163 deletions(-) diff --git a/homeassistant/components/device_automation/condition.py b/homeassistant/components/device_automation/condition.py index dde1ee7bfe081..b77efaeb65873 100644 --- a/homeassistant/components/device_automation/condition.py +++ b/homeassistant/components/device_automation/condition.py @@ -11,7 +11,6 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.condition import ( Condition, - ConditionChecker, ConditionCheckerType, ConditionConfig, ) @@ -54,6 +53,7 @@ class DeviceCondition(Condition): """Device condition.""" _config: ConfigType + _platform_checker: ConditionCheckerType @classmethod async def async_validate_complete_config( @@ -87,20 +87,19 @@ def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: assert config.options is not None self._config = config.options - async def async_get_checker(self) -> ConditionChecker: - """Test a device condition.""" + async def async_setup(self) -> None: + """Set up a device condition.""" platform = await async_get_device_automation_platform( self._hass, self._config[CONF_DOMAIN], DeviceAutomationType.CONDITION ) - platform_checker = platform.async_condition_from_config( + self._platform_checker = platform.async_condition_from_config( self._hass, self._config ) - def checker(variables: TemplateVarsType = None, **kwargs: Any) -> bool: - result = platform_checker(self._hass, variables) - return result is not False - - return checker + def _async_check(self, variables: TemplateVarsType = None, **kwargs: Any) -> bool: + """Check the condition.""" + result = self._platform_checker(self._hass, variables) + return result is not False CONDITIONS: dict[str, type[Condition]] = { diff --git a/homeassistant/components/sun/condition.py b/homeassistant/components/sun/condition.py index 40a6eb652c430..90686ab9add20 100644 --- a/homeassistant/components/sun/condition.py +++ b/homeassistant/components/sun/condition.py @@ -13,7 +13,6 @@ from homeassistant.helpers.automation import move_top_level_schema_fields_to_options from homeassistant.helpers.condition import ( Condition, - ConditionChecker, ConditionCheckParams, ConditionConfig, condition_trace_set_result, @@ -151,19 +150,20 @@ def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: super().__init__(hass, config) assert config.options is not None self._options = config.options - - async def async_get_checker(self) -> ConditionChecker: - """Wrap action method with sun based condition.""" - before = self._options.get("before") - after = self._options.get("after") - before_offset = self._options.get("before_offset") - after_offset = self._options.get("after_offset") - - def sun_if(**kwargs: Unpack[ConditionCheckParams]) -> bool: - """Validate time based if-condition.""" - return sun(self._hass, before, after, before_offset, after_offset) - - return sun_if + self._before = self._options.get("before") + self._after = self._options.get("after") + self._before_offset = self._options.get("before_offset") + self._after_offset = self._options.get("after_offset") + + def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: + """Check the condition.""" + return sun( + self._hass, + self._before, + self._after, + self._before_offset, + self._after_offset, + ) CONDITIONS: dict[str, type[Condition]] = { diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py index ee3f286c6601b..bb9c1cb2fd02f 100644 --- a/homeassistant/components/zone/condition.py +++ b/homeassistant/components/zone/condition.py @@ -22,7 +22,6 @@ from homeassistant.helpers.automation import move_top_level_schema_fields_to_options from homeassistant.helpers.condition import ( Condition, - ConditionChecker, ConditionCheckParams, ConditionConfig, ) @@ -117,44 +116,39 @@ def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: super().__init__(hass, config) assert config.options is not None self._options = config.options - - async def async_get_checker(self) -> ConditionChecker: - """Wrap action method with zone based condition.""" - entity_ids = self._options.get(CONF_ENTITY_ID, []) - zone_entity_ids = self._options.get(CONF_ZONE, []) - - def if_in_zone(**kwargs: Unpack[ConditionCheckParams]) -> bool: - """Test if condition.""" - errors = [] - - all_ok = True - for entity_id in entity_ids: - entity_ok = False - for zone_entity_id in zone_entity_ids: - try: - if zone(self._hass, zone_entity_id, entity_id): - entity_ok = True - except ConditionErrorMessage as ex: - errors.append( - ConditionErrorMessage( - "zone", - ( - f"error matching {entity_id} with {zone_entity_id}:" - f" {ex.message}" - ), - ) + self._entity_ids = self._options.get(CONF_ENTITY_ID, []) + self._zone_entity_ids = self._options.get(CONF_ZONE, []) + + def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: + """Test if condition.""" + errors = [] + + all_ok = True + for entity_id in self._entity_ids: + entity_ok = False + for zone_entity_id in self._zone_entity_ids: + try: + if zone(self._hass, zone_entity_id, entity_id): + entity_ok = True + except ConditionErrorMessage as ex: + errors.append( + ConditionErrorMessage( + "zone", + ( + f"error matching {entity_id} with {zone_entity_id}:" + f" {ex.message}" + ), ) + ) - if not entity_ok: - all_ok = False - - # Raise the errors only if no definitive result was found - if errors and not all_ok: - raise ConditionErrorContainer("zone", errors=errors) + if not entity_ok: + all_ok = False - return all_ok + # Raise the errors only if no definitive result was found + if errors and not all_ok: + raise ConditionErrorContainer("zone", errors=errors) - return if_in_zone + return all_ok CONDITIONS: dict[str, type[Condition]] = { diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 4acaec51746da..adaea77fc3114 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -18,6 +18,7 @@ Any, Final, Literal, + Never, Protocol, TypedDict, Unpack, @@ -284,10 +285,80 @@ async def _register_condition_platform( ) -class Condition(abc.ABC): - """Condition class.""" +class ConditionChecker(abc.ABC): + """Base class for condition checkers.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize condition checker.""" + self._hass = hass + self._unloaded = False + + def __call__( + self, hass: HomeAssistant, variables: TemplateVarsType = None + ) -> bool | None: + """Check the condition. + + `hass` parameter is for backwards compatibility only and is always ignored. + """ + return self.async_check(variables=variables) + + def __del__(self) -> None: + """Clean up when the checker is deleted.""" + if self._unloaded: + return + try: + self.async_unload() + except Exception: + _LOGGER.exception("Error while unloading condition checker") + + async def async_setup(self) -> None: + """Set up the condition checker. + + Intended to be overridden in derived classes that need to do setup. + """ + + def async_unload(self) -> None: + """Clean up any resources held by the checker. + + Intended to be overridden in derived classes that need to do unloading. + """ + self._unloaded = True + + def async_check( + self, *, variables: TemplateVarsType = None, **kwargs: Never + ) -> bool | None: + """Check the condition.""" + with trace_condition(variables): + result = self._async_check(variables=variables) + condition_trace_update_result(result=result) + return result + + @abc.abstractmethod + def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool | None: + """Check the condition.""" - _hass: HomeAssistant + +class LegacyConditionChecker(ConditionChecker): + """Condition checker wrapping a legacy condition factory function.""" + + def __init__(self, hass: HomeAssistant, checker: ConditionCheckerType) -> None: + """Initialize condition checker.""" + super().__init__(hass) + self._checker = checker + + def _async_check(self, variables: TemplateVarsType = None, **kwargs: Any) -> bool: + return self._checker(self._hass, variables) + + +class DisabledConditionChecker(ConditionChecker): + """Condition checker for disabled conditions.""" + + def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> None: + return None + + +class Condition(ConditionChecker): + """Condition class.""" @classmethod async def async_validate_complete_config( @@ -323,11 +394,7 @@ async def async_validate_config( def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: """Initialize condition.""" - self._hass = hass - - @abc.abstractmethod - async def async_get_checker(self) -> ConditionChecker: - """Get the condition checker.""" + super().__init__(hass) ATTR_BEHAVIOR: Final = "behavior" @@ -379,6 +446,10 @@ def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: self._target_selection = TargetSelection(config.target) self._behavior = config.options[ATTR_BEHAVIOR] self._duration: timedelta | None = config.options.get(CONF_FOR) + if self._behavior == BEHAVIOR_ANY: + self._matcher = self._check_any_match_state + elif self._behavior == BEHAVIOR_ALL: + self._matcher = self._check_all_match_state def entity_filter(self, entities: set[str]) -> set[str]: """Filter entities matching any of the domain specs.""" @@ -395,56 +466,44 @@ def _get_tracked_value(self, entity_state: State) -> Any: def is_valid_state(self, entity_state: State) -> bool: """Check if the state matches the expected state(s).""" - @override - async def async_get_checker(self) -> ConditionChecker: - """Get the condition checker.""" - - def check_any_match_state(states: list[State]) -> bool: - """Test if any entity matches the state.""" - if not self._duration: - # Skip duration check if duration is not specified or 0 - return any(self.is_valid_state(state) for state in states) - duration = dt_util.utcnow() - self._duration - return any( - self.is_valid_state(state) and duration > state.last_changed - for state in states - ) - - def check_all_match_state(states: list[State]) -> bool: - """Test if all entities match the state.""" - if not self._duration: - # Skip duration check if duration is not specified or 0 - return all(self.is_valid_state(state) for state in states) - duration = dt_util.utcnow() - self._duration - return all( - self.is_valid_state(state) and duration > state.last_changed - for state in states - ) - - matcher: Callable[[list[State]], bool] - if self._behavior == BEHAVIOR_ANY: - matcher = check_any_match_state - elif self._behavior == BEHAVIOR_ALL: - matcher = check_all_match_state + def _check_any_match_state(self, states: list[State]) -> bool: + """Test if any entity matches the state.""" + if not self._duration: + # Skip duration check if duration is not specified or 0 + return any(self.is_valid_state(state) for state in states) + duration = dt_util.utcnow() - self._duration + return any( + self.is_valid_state(state) and duration > state.last_changed + for state in states + ) - def test_state(**kwargs: Unpack[ConditionCheckParams]) -> bool: - """Test state condition.""" - targeted_entities = async_extract_referenced_entity_ids( - self._hass, self._target_selection, expand_group=False - ) - referenced_entity_ids = targeted_entities.referenced.union( - targeted_entities.indirectly_referenced - ) - filtered_entity_ids = self.entity_filter(referenced_entity_ids) - entity_states = [ - _state - for entity_id in filtered_entity_ids - if (_state := self._hass.states.get(entity_id)) - and _state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) - ] - return matcher(entity_states) + def _check_all_match_state(self, states: list[State]) -> bool: + """Test if all entities match the state.""" + if not self._duration: + # Skip duration check if duration is not specified or 0 + return all(self.is_valid_state(state) for state in states) + duration = dt_util.utcnow() - self._duration + return all( + self.is_valid_state(state) and duration > state.last_changed + for state in states + ) - return test_state + def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: + """Test state condition.""" + targeted_entities = async_extract_referenced_entity_ids( + self._hass, self._target_selection, expand_group=False + ) + referenced_entity_ids = targeted_entities.referenced.union( + targeted_entities.indirectly_referenced + ) + filtered_entity_ids = self.entity_filter(referenced_entity_ids) + entity_states = [ + _state + for entity_id in filtered_entity_ids + if (_state := self._hass.states.get(entity_id)) + and _state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + ] + return self._matcher(entity_states) class EntityStateConditionBase(EntityConditionBase): @@ -739,13 +798,6 @@ class ConditionCheckParams(TypedDict, total=False): variables: TemplateVarsType -class ConditionChecker(Protocol): - """Protocol for condition checker callable with typed kwargs.""" - - def __call__(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: - """Check the condition.""" - - type ConditionCheckerType = Callable[[HomeAssistant, TemplateVarsType], bool] type ConditionCheckerTypeOptional = Callable[ [HomeAssistant, TemplateVarsType], bool | None @@ -869,20 +921,10 @@ async def _async_get_condition_platform( return platform, platform_module -async def _async_get_checker(condition: Condition) -> ConditionCheckerType: - new_checker = await condition.async_get_checker() - - @trace_condition_function - def checker(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: - return new_checker(variables=variables) - - return checker - - async def async_from_config( hass: HomeAssistant, config: ConfigType, -) -> ConditionCheckerTypeOptional: +) -> ConditionChecker: """Turn a condition configuration into a method. Should be run on the event loop. @@ -898,15 +940,7 @@ async def async_from_config( f"Error rendering condition enabled template: {err}" ) from err if not enabled: - - @trace_condition_function - def disabled_condition( - hass: HomeAssistant, variables: TemplateVarsType = None - ) -> bool | None: - """Condition not enabled, will act as if it didn't exist.""" - return None - - return disabled_condition + return DisabledConditionChecker(hass) condition_key: str = config[CONF_CONDITION] factory: Any = None @@ -925,7 +959,8 @@ def disabled_condition( target=config.get(CONF_TARGET), ), ) - return await _async_get_checker(condition) + await condition.async_setup() + return condition for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT): factory = getattr(sys.modules[__name__], fmt.format(condition_key), None) @@ -939,8 +974,10 @@ def disabled_condition( check_factory = check_factory.func if inspect.iscoroutinefunction(check_factory): - return cast(ConditionCheckerType, await factory(hass, config)) - return cast(ConditionCheckerType, factory(config)) + checker = cast(ConditionCheckerType, await factory(hass, config)) + else: + checker = cast(ConditionCheckerType, factory(config)) + return LegacyConditionChecker(hass, checker) async def async_and_from_config( @@ -949,7 +986,6 @@ async def async_and_from_config( """Create multi condition matcher using 'AND'.""" checks = [await async_from_config(hass, entry) for entry in config["conditions"]] - @trace_condition_function def if_and_condition( hass: HomeAssistant, variables: TemplateVarsType = None ) -> bool: @@ -980,7 +1016,6 @@ async def async_or_from_config( """Create multi condition matcher using 'OR'.""" checks = [await async_from_config(hass, entry) for entry in config["conditions"]] - @trace_condition_function def if_or_condition( hass: HomeAssistant, variables: TemplateVarsType = None ) -> bool: @@ -1011,7 +1046,6 @@ async def async_not_from_config( """Create multi condition matcher using 'NOT'.""" checks = [await async_from_config(hass, entry) for entry in config["conditions"]] - @trace_condition_function def if_not_condition( hass: HomeAssistant, variables: TemplateVarsType = None ) -> bool: @@ -1191,7 +1225,6 @@ def async_numeric_state_from_config(config: ConfigType) -> ConditionCheckerType: above = config.get(CONF_ABOVE) value_template = config.get(CONF_VALUE_TEMPLATE) - @trace_condition_function def if_numeric_state( hass: HomeAssistant, variables: TemplateVarsType = None ) -> bool: @@ -1310,7 +1343,6 @@ def state_from_config(config: ConfigType) -> ConditionCheckerType: if not isinstance(req_states, list): req_states = [req_states] - @trace_condition_function def if_state(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: """Test if condition.""" errors = [] @@ -1372,7 +1404,6 @@ def async_template_from_config(config: ConfigType) -> ConditionCheckerType: """Wrap action method with state based condition.""" value_template = cast(Template, config.get(CONF_VALUE_TEMPLATE)) - @trace_condition_function def template_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: """Validate template based if-condition.""" return async_template(hass, value_template, variables) @@ -1485,7 +1516,6 @@ def time_from_config(config: ConfigType) -> ConditionCheckerType: after = config.get(CONF_AFTER) weekday = config.get(CONF_WEEKDAY) - @trace_condition_function def time_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: """Validate time based if-condition.""" return time(hass, before, after, weekday) @@ -1499,7 +1529,6 @@ async def async_trigger_from_config( """Test a trigger condition.""" trigger_id = config[CONF_ID] - @trace_condition_function def trigger_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: """Validate trigger based if-condition.""" return ( diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index d4aff4167e8ba..63f1cf6dfd1d6 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -2150,16 +2150,16 @@ async def async_validate_config( class MockCondition1(MockCondition): """Mock condition 1.""" - async def async_get_checker(self) -> ConditionChecker: - """Evaluate state based on configuration.""" - return lambda **kwargs: True + def _async_check(self, **kwargs) -> bool: + """Check the condition.""" + return True class MockCondition2(MockCondition): """Mock condition 2.""" - async def async_get_checker(self) -> ConditionChecker: - """Evaluate state based on configuration.""" - return lambda **kwargs: False + def _async_check(self, **kwargs) -> bool: + """Check the condition.""" + return False async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: return { @@ -2297,8 +2297,9 @@ async def async_validate_config( ) -> ConfigType: return config - async def async_get_checker(self) -> ConditionChecker: - return lambda **kwargs: True + def _async_check(self, **kwargs) -> bool: + """Check the condition.""" + return True async def async_get_conditions( hass: HomeAssistant, @@ -3103,7 +3104,7 @@ async def _setup_numerical_condition( entity_ids: str | list[str], domain_specs: Mapping[str, DomainSpec] | None = None, valid_unit: str | None | UndefinedType = UNDEFINED, -) -> condition.ConditionCheckerType: +) -> condition.ConditionChecker: """Set up a numerical condition via a mock platform and return the test.""" condition_cls = make_entity_numerical_condition( domain_specs or _DEFAULT_DOMAIN_SPECS, valid_unit @@ -3432,7 +3433,7 @@ async def _setup_numerical_condition_with_unit( domain_specs: Mapping[str, DomainSpec] | None = None, base_unit: str = UnitOfTemperature.CELSIUS, unit_converter: type = TemperatureConverter, -) -> condition.ConditionCheckerType: +) -> condition.ConditionChecker: """Set up a numerical condition with unit conversion via a mock platform.""" condition_cls = make_entity_numerical_condition_with_unit( domain_specs or _DEFAULT_DOMAIN_SPECS, base_unit, unit_converter @@ -3953,7 +3954,7 @@ async def _setup_state_condition( condition_options: dict[str, Any] | None = None, domain_specs: Mapping[str, DomainSpec] | None = None, support_duration: bool = False, -) -> condition.ConditionCheckerType: +) -> condition.ConditionChecker: """Set up a state condition via a mock platform and return the checker.""" condition_cls = make_entity_state_condition( domain_specs or _DEFAULT_DOMAIN_SPECS, @@ -4341,3 +4342,47 @@ async def test_state_condition_duration_unavailable_unknown( await hass.async_block_till_done() freezer.tick(timedelta(seconds=11)) assert test_all(hass) is False + + +async def test_condition_checker_del_calls_async_unload( + hass: HomeAssistant, +) -> None: + """Test that __del__ calls async_unload if not already called.""" + + class MockChecker(ConditionChecker): + def _async_check(self, **kwargs: Any) -> bool: + return True + + checker = MockChecker(hass) + unload_mock = Mock(wraps=checker.async_unload) + checker.async_unload = unload_mock + + # Pylint says we should `del checker`. However, that's not guaranteed + # to immediately call __del__. + checker.__del__() # pylint: disable=unnecessary-dunder-call + unload_mock.assert_called_once() + + +async def test_condition_checker_del_skips_if_already_unloaded( + hass: HomeAssistant, +) -> None: + """Test that __del__ does not call async_unload if already called.""" + + class MockChecker(ConditionChecker): + def _async_check(self, **kwargs: Any) -> bool: + return True + + checker = MockChecker(hass) + unload_mock = Mock(wraps=checker.async_unload) + checker.async_unload = unload_mock + + # First call sets the flag + checker.async_unload() + unload_mock.assert_called_once() + unload_mock.reset_mock() + + # __del__ should skip since _unloaded is True + # Pylint says we should `del checker`. However, that's not guaranteed + # to immediately call __del__. + checker.__del__() # pylint: disable=unnecessary-dunder-call + unload_mock.assert_not_called() From 6a57382effbf1c7356f86f4e267e43a31921ee60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 24 Apr 2026 08:13:51 +0100 Subject: [PATCH 02/14] Allow extracting non-primary entities in websocket command (#168860) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/websocket_api/commands.py | 6 +- homeassistant/helpers/target.py | 79 ++++++++++++----- .../components/websocket_api/test_commands.py | 88 ++++++++++++++++++- tests/helpers/test_target.py | 31 +++++++ 4 files changed, 178 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index e083a8253b14a..b99e865112186 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -865,6 +865,7 @@ def handle_entity_source( vol.Required("type"): "extract_from_target", vol.Required("target"): cv.TARGET_FIELDS, vol.Optional("expand_group", default=False): bool, + vol.Optional("primary_entities_only", default=True): bool, } ) def handle_extract_from_target( @@ -874,7 +875,10 @@ def handle_extract_from_target( target_selection = target_helpers.TargetSelection(msg["target"]) extracted = target_helpers.async_extract_referenced_entity_ids( - hass, target_selection, expand_group=msg["expand_group"] + hass, + target_selection, + expand_group=msg["expand_group"], + primary_entities_only=msg["primary_entities_only"], ) extracted_dict = { diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index 334b7147e01b0..b5d5f3fbd8f8b 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -147,9 +147,22 @@ def log_missing(self, missing_entities: set[str], logger: Logger) -> None: def async_extract_referenced_entity_ids( - hass: HomeAssistant, target_selection: TargetSelection, expand_group: bool = True + hass: HomeAssistant, + target_selection: TargetSelection, + expand_group: bool = True, + *, + primary_entities_only: bool = True, ) -> SelectedEntities: - """Extract referenced entity IDs from a target selection.""" + """Extract referenced entity IDs from a target selection. + + When `primary_entities_only` is True (the default), entities with an + `entity_category` (i.e. config or diagnostic entities) are excluded from + indirect expansion via device, area, and floor. When False, those entities + are included. Direct label-to-entity expansion is unaffected by this flag. + Label targeting via labeled devices or areas follows the same filtering + rules as other indirect device/area expansion paths: filtered when + `primary_entities_only` is True, and included when it is False. + """ selected = SelectedEntities() if not target_selection.has_any_target: @@ -217,14 +230,18 @@ def async_extract_referenced_entity_ids( if not selected.referenced_areas and not selected.referenced_devices: return selected + def _include_entry(entry: er.RegistryEntry) -> bool: + """Return True if the entry should be included in indirect expansion.""" + if entry.hidden_by is not None: + return False + return not primary_entities_only or entry.entity_category is None + # Add indirectly referenced by device selected.indirectly_referenced.update( entry.entity_id for device_id in selected.referenced_devices for entry in entities.get_entries_for_device_id(device_id) - # Do not add entities which are hidden or which are config - # or diagnostic entities. - if (entry.entity_category is None and entry.hidden_by is None) + if _include_entry(entry) ) # Find devices for targeted areas @@ -243,27 +260,16 @@ def async_extract_referenced_entity_ids( for area_id in selected.referenced_areas # The entity's area matches a targeted area for entry in entities.get_entries_for_area_id(area_id) - # Do not add entities which are hidden or which are config - # or diagnostic entities. - if entry.entity_category is None and entry.hidden_by is None + if _include_entry(entry) ) # Add indirectly referenced by area through device selected.indirectly_referenced.update( entry.entity_id for device_id in referenced_devices_by_area for entry in entities.get_entries_for_device_id(device_id) - # Do not add entities which are hidden or which are config - # or diagnostic entities. - if ( - entry.entity_category is None - and entry.hidden_by is None - and ( - # The entity's device matches a device referenced - # by an area and the entity - # has no explicitly set area - not entry.area_id - ) - ) + # The entity's device matches a device referenced by an area and the + # entity has no explicitly set area. + if _include_entry(entry) and not entry.area_id ) return selected @@ -277,11 +283,14 @@ def __init__( hass: HomeAssistant, target_selection: TargetSelection, entity_filter: Callable[[set[str]], set[str]], + *, + primary_entities_only: bool = True, ) -> None: """Initialize the state change tracker.""" self._hass = hass self._target_selection = target_selection self._entity_filter = entity_filter + self._primary_entities_only = primary_entities_only self._registry_unsubs: list[CALLBACK_TYPE] = [] @@ -300,7 +309,10 @@ def _handle_entities_update(self, tracked_entities: set[str]) -> None: def _handle_target_update(self, event: Event[Any] | None = None) -> None: """Handle updates in the tracked targets.""" selected = async_extract_referenced_entity_ids( - self._hass, self._target_selection, expand_group=False + self._hass, + self._target_selection, + expand_group=False, + primary_entities_only=self._primary_entities_only, ) filtered_entities = self._entity_filter( selected.referenced | selected.indirectly_referenced @@ -345,9 +357,16 @@ def __init__( target_selection: TargetSelection, action: Callable[[TargetStateChangedData], Any], entity_filter: Callable[[set[str]], set[str]], + *, + primary_entities_only: bool = True, ) -> None: """Initialize the state change tracker.""" - super().__init__(hass, target_selection, entity_filter) + super().__init__( + hass, + target_selection, + entity_filter, + primary_entities_only=primary_entities_only, + ) self._action = action self._state_change_unsub: CALLBACK_TYPE | None = None @@ -380,12 +399,24 @@ def async_track_target_selector_state_change_event( target_selector_config: ConfigType, action: Callable[[TargetStateChangedData], Any], entity_filter: Callable[[set[str]], set[str]] = lambda x: x, + *, + primary_entities_only: bool = True, ) -> CALLBACK_TYPE: - """Track state changes for entities referenced directly or indirectly in a target selector.""" + """Track state changes for entities referenced directly or indirectly in a target selector. + + When `primary_entities_only` is True, indirect target expansion (via device, area, + and floor) skips entities with an `entity_category` (i.e. config or diagnostic entities). + """ target_selection = TargetSelection(target_selector_config) if not target_selection.has_any_target: raise HomeAssistantError( f"Target selector {target_selector_config} does not have any selectors defined" ) - tracker = TargetStateChangeTracker(hass, target_selection, action, entity_filter) + tracker = TargetStateChangeTracker( + hass, + target_selection, + action, + entity_filter, + primary_entities_only=primary_entities_only, + ) return tracker.async_setup() diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index a0b2943cc1eb2..10cc6fc7d6bf8 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -34,7 +34,11 @@ ) from homeassistant.components.websocket_api.const import FEATURE_COALESCE_MESSAGES, URL from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_EXTERNAL_URL, SIGNAL_BOOTSTRAP_INTEGRATIONS +from homeassistant.const import ( + CONF_EXTERNAL_URL, + SIGNAL_BOOTSTRAP_INTEGRATIONS, + EntityCategory, +) from homeassistant.core import Context, HomeAssistant, State, SupportsResponse, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import ( @@ -3604,6 +3608,88 @@ async def test_extract_from_target_expand_group( ) +async def test_extract_from_target_primary_entities_only( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test extract_from_target command with primary_entities_only parameter.""" + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "device1")}, + ) + + primary_entity = entity_registry.async_get_or_create( + "light", "test", "unique1", device_id=device.id + ) + diagnostic_entity = entity_registry.async_get_or_create( + "sensor", + "test", + "unique2", + device_id=device.id, + entity_category=EntityCategory.DIAGNOSTIC, + ) + config_entity = entity_registry.async_get_or_create( + "switch", + "test", + "unique3", + device_id=device.id, + entity_category=EntityCategory.CONFIG, + ) + + # Default (primary_entities_only=True): config/diagnostic entities excluded + await websocket_client.send_json_auto_id( + { + "type": "extract_from_target", + "target": {"device_id": [device.id]}, + } + ) + msg = await websocket_client.receive_json() + _assert_extract_from_target_command_result( + msg, + entities={primary_entity.entity_id}, + devices={device.id}, + ) + + # Explicit primary_entities_only=True + await websocket_client.send_json_auto_id( + { + "type": "extract_from_target", + "target": {"device_id": [device.id]}, + "primary_entities_only": True, + } + ) + msg = await websocket_client.receive_json() + _assert_extract_from_target_command_result( + msg, + entities={primary_entity.entity_id}, + devices={device.id}, + ) + + # primary_entities_only=False: config/diagnostic entities included + await websocket_client.send_json_auto_id( + { + "type": "extract_from_target", + "target": {"device_id": [device.id]}, + "primary_entities_only": False, + } + ) + msg = await websocket_client.receive_json() + _assert_extract_from_target_command_result( + msg, + entities={ + primary_entity.entity_id, + diagnostic_entity.entity_id, + config_entity.entity_id, + }, + devices={device.id}, + ) + + async def test_extract_from_target_missing_entities( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: diff --git a/tests/helpers/test_target.py b/tests/helpers/test_target.py index 92a8a0e2ee2a3..114e3445bf057 100644 --- a/tests/helpers/test_target.py +++ b/tests/helpers/test_target.py @@ -499,6 +499,37 @@ async def test_extract_referenced_entity_ids( ) +@pytest.mark.parametrize( + ("selector_config", "non_primary_entities"), + [ + ({ATTR_AREA_ID: "own-area"}, {"light.config_in_own_area"}), + ({ATTR_DEVICE_ID: "device-no-area-id"}, {"light.config_no_area"}), + ({ATTR_AREA_ID: "test-area"}, {"light.config_in_area"}), + ], +) +@pytest.mark.usefixtures("registries_mock") +async def test_extract_referenced_entity_ids_primary_entities_only( + hass: HomeAssistant, + selector_config: ConfigType, + non_primary_entities: set[str], +) -> None: + """Test that primary_entities_only controls inclusion of config/diagnostic entities.""" + target_selection = target.TargetSelection(selector_config) + + selected_primary = target.async_extract_referenced_entity_ids( + hass, target_selection, expand_group=False, primary_entities_only=True + ) + selected_all = target.async_extract_referenced_entity_ids( + hass, target_selection, expand_group=False, primary_entities_only=False + ) + + assert ( + selected_all.indirectly_referenced + == selected_primary.indirectly_referenced | non_primary_entities + ) + assert non_primary_entities.isdisjoint(selected_primary.indirectly_referenced) + + async def test_async_track_target_selector_state_change_event_empty_selector( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From f8e6137d28303de4b0479a6e59bdbebc5d703754 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 24 Apr 2026 09:24:05 +0200 Subject: [PATCH 03/14] Update fumis to v0.3.0 (#168984) --- homeassistant/components/fumis/manifest.json | 2 +- homeassistant/components/fumis/sensor.py | 5 +---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/fumis/manifest.json b/homeassistant/components/fumis/manifest.json index d037fff422c04..0ab8c7be5d063 100644 --- a/homeassistant/components/fumis/manifest.json +++ b/homeassistant/components/fumis/manifest.json @@ -13,5 +13,5 @@ "iot_class": "cloud_polling", "loggers": ["fumis"], "quality_scale": "bronze", - "requirements": ["fumis==0.2.1"] + "requirements": ["fumis==0.3.0"] } diff --git a/homeassistant/components/fumis/sensor.py b/homeassistant/components/fumis/sensor.py index d41798e21f6a5..78c48b35f4552 100644 --- a/homeassistant/components/fumis/sensor.py +++ b/homeassistant/components/fumis/sensor.py @@ -202,10 +202,7 @@ class FumisSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: ( - data.controller.time_to_service is not None - and data.controller.time_to_service >= 0 - ), + has_fn=lambda data: data.controller.time_to_service is not None, value_fn=lambda data: data.controller.time_to_service, ), FumisSensorEntityDescription( diff --git a/requirements_all.txt b/requirements_all.txt index 0a0c300813e34..239395aaee17f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1038,7 +1038,7 @@ fressnapftracker==0.2.2 fritzconnection[qr]==1.15.1 # homeassistant.components.fumis -fumis==0.2.1 +fumis==0.3.0 # homeassistant.components.fyta fyta_cli==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 33900534a18de..53972fd943a70 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -920,7 +920,7 @@ fressnapftracker==0.2.2 fritzconnection[qr]==1.15.1 # homeassistant.components.fumis -fumis==0.2.1 +fumis==0.3.0 # homeassistant.components.fyta fyta_cli==0.7.2 From 3ff2b4424f2181aa2ffd133477a0cb10a35dc996 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:44:08 +0200 Subject: [PATCH 04/14] Bump uiprotect to 10.3.1 (#169031) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index c4e0a48f5325e..8a79f2e0d5487 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==10.3.0"] + "requirements": ["uiprotect==10.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 239395aaee17f..03f47181363d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3188,7 +3188,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==10.3.0 +uiprotect==10.3.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 53972fd943a70..1f075721a45eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2709,7 +2709,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==10.3.0 +uiprotect==10.3.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From ed7f2b181097830a6bcea311be9c24746529dab0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 24 Apr 2026 09:44:28 +0200 Subject: [PATCH 05/14] Migrate compound conditions to ConditionChecker (#169028) --- homeassistant/helpers/condition.py | 89 ++++++++++++++++--------- tests/helpers/test_condition.py | 102 +++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 30 deletions(-) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index adaea77fc3114..112de4e4d8bda 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -357,6 +357,21 @@ def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> None: return None +class CompoundConditionChecker(ConditionChecker): + """Base class for compound condition checkers (and/or/not).""" + + def __init__(self, hass: HomeAssistant, checks: list[ConditionChecker]) -> None: + """Initialize condition checker.""" + super().__init__(hass) + self._checks = checks + + def async_unload(self) -> None: + """Clean up child conditions.""" + for check in self._checks: + check.async_unload() + super().async_unload() + + class Condition(ConditionChecker): """Condition class.""" @@ -974,31 +989,39 @@ async def async_from_config( check_factory = check_factory.func if inspect.iscoroutinefunction(check_factory): - checker = cast(ConditionCheckerType, await factory(hass, config)) + checker = await factory(hass, config) else: - checker = cast(ConditionCheckerType, factory(config)) - return LegacyConditionChecker(hass, checker) + checker = factory(config) + if isinstance(checker, ConditionChecker): + return checker + return LegacyConditionChecker(hass, cast(ConditionCheckerType, checker)) async def async_and_from_config( hass: HomeAssistant, config: ConfigType -) -> ConditionCheckerType: +) -> ConditionChecker: """Create multi condition matcher using 'AND'.""" checks = [await async_from_config(hass, entry) for entry in config["conditions"]] + return AndConditionChecker(hass, checks) - def if_and_condition( - hass: HomeAssistant, variables: TemplateVarsType = None - ) -> bool: + +class AndConditionChecker(CompoundConditionChecker): + """Condition checker for 'and' compound conditions.""" + + @callback + def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: """Test and condition.""" errors = [] - for index, check in enumerate(checks): + for index, check in enumerate(self._checks): try: with trace_path(["conditions", str(index)]): - if check(hass, variables) is False: + if check(self._hass, **kwargs) is False: return False except ConditionError as ex: errors.append( - ConditionErrorIndex("and", index=index, total=len(checks), error=ex) + ConditionErrorIndex( + "and", index=index, total=len(self._checks), error=ex + ) ) # Raise the errors if no check was false @@ -1007,28 +1030,32 @@ def if_and_condition( return True - return if_and_condition - async def async_or_from_config( hass: HomeAssistant, config: ConfigType -) -> ConditionCheckerType: +) -> ConditionChecker: """Create multi condition matcher using 'OR'.""" checks = [await async_from_config(hass, entry) for entry in config["conditions"]] + return OrConditionChecker(hass, checks) - def if_or_condition( - hass: HomeAssistant, variables: TemplateVarsType = None - ) -> bool: + +class OrConditionChecker(CompoundConditionChecker): + """Condition checker for 'or' compound conditions.""" + + @callback + def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: """Test or condition.""" errors = [] - for index, check in enumerate(checks): + for index, check in enumerate(self._checks): try: with trace_path(["conditions", str(index)]): - if check(hass, variables) is True: + if check(self._hass, **kwargs) is True: return True except ConditionError as ex: errors.append( - ConditionErrorIndex("or", index=index, total=len(checks), error=ex) + ConditionErrorIndex( + "or", index=index, total=len(self._checks), error=ex + ) ) # Raise the errors if no check was true @@ -1037,28 +1064,32 @@ def if_or_condition( return False - return if_or_condition - async def async_not_from_config( hass: HomeAssistant, config: ConfigType -) -> ConditionCheckerType: +) -> ConditionChecker: """Create multi condition matcher using 'NOT'.""" checks = [await async_from_config(hass, entry) for entry in config["conditions"]] + return NotConditionChecker(hass, checks) - def if_not_condition( - hass: HomeAssistant, variables: TemplateVarsType = None - ) -> bool: + +class NotConditionChecker(CompoundConditionChecker): + """Condition checker for 'not' compound conditions.""" + + @callback + def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: """Test not condition.""" errors = [] - for index, check in enumerate(checks): + for index, check in enumerate(self._checks): try: with trace_path(["conditions", str(index)]): - if check(hass, variables): + if check(self._hass, **kwargs): return False except ConditionError as ex: errors.append( - ConditionErrorIndex("not", index=index, total=len(checks), error=ex) + ConditionErrorIndex( + "not", index=index, total=len(self._checks), error=ex + ) ) # Raise the errors if no check was true @@ -1067,8 +1098,6 @@ def if_not_condition( return True - return if_not_condition - def numeric_state( hass: HomeAssistant, diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 63f1cf6dfd1d6..e5e64ba7d36cb 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -4386,3 +4386,105 @@ def _async_check(self, **kwargs: Any) -> bool: # to immediately call __del__. checker.__del__() # pylint: disable=unnecessary-dunder-call unload_mock.assert_not_called() + + +async def _setup_mock_integration(hass: HomeAssistant) -> None: + """Set up a mock integration with conditions.""" + + class MockCondition(Condition): + def __new__(cls, *args: Any, **kwargs: Any) -> Condition: + """Return a mock instance that tracks async_setup and async_unload calls.""" + mocked = Mock(spec=Condition) + mocked.async_setup = AsyncMock() + mocked.async_unload = Mock() + return mocked + + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return config # Return the config unchanged for testing + + def _async_check(self, **kwargs: Any) -> bool | None: + """Check the condition.""" + raise NotImplementedError + + async def async_get_conditions( + hass: HomeAssistant, + ) -> dict[str, type[Condition]]: + return {"_": MockCondition} + + mock_integration(hass, MockModule("test")) + mock_platform( + hass, "test.condition", Mock(async_get_conditions=async_get_conditions) + ) + + +@pytest.mark.parametrize( + "compound_type", + ["and", "or", "not"], +) +async def test_compound_condition_forwards_async_unload( + hass: HomeAssistant, compound_type: str +) -> None: + """Test that and/or/not compound conditions forward async_unload to children.""" + await _setup_mock_integration(hass) + config = { + "condition": compound_type, + "conditions": [ + {"condition": "test"}, + {"condition": "test"}, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + # The compound checker should hold child checkers + assert hasattr(test, "_checks") + assert len(test._checks) == 2 + + test.async_unload() + + for child in test._checks: + child.async_unload.assert_called_once() + + +@pytest.mark.parametrize( + ("outer_type", "inner_type"), + [ + (outer, inner) + for outer in ("and", "or", "not") + for inner in ("and", "or", "not") + ], +) +async def test_nested_compound_condition_forwards_async_unload( + hass: HomeAssistant, outer_type: str, inner_type: str +) -> None: + """Test that nested compound conditions forward async_unload recursively.""" + await _setup_mock_integration(hass) + config = { + "condition": outer_type, + "conditions": [ + { + "condition": inner_type, + "conditions": [{"condition": "test"}], + }, + {"condition": "test"}, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + # Outer compound with 2 children: an inner compound and a leaf + assert len(test._checks) == 2 + inner_checker = test._checks[0] + assert hasattr(inner_checker, "_checks") + assert len(inner_checker._checks) == 1 + + test.async_unload() + + test._checks[0]._checks[0].async_unload.assert_called_once() + test._checks[1].async_unload.assert_called_once() From 4d8f3dfaf71e474d9bbbd3093bdea328e3da6b58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Fri, 24 Apr 2026 09:44:43 +0200 Subject: [PATCH 06/14] Update Tibber library, 0.37.2 (#169027) --- homeassistant/components/tibber/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 066d2cced5fe2..774f4d0ee4a0e 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tibber"], - "requirements": ["pyTibber==0.37.1"] + "requirements": ["pyTibber==0.37.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 03f47181363d2..8150f9152892b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1941,7 +1941,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.37.1 +pyTibber==0.37.2 # homeassistant.components.dlink pyW215==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1f075721a45eb..35e651d15a4fd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1684,7 +1684,7 @@ pyHomee==1.3.8 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.37.1 +pyTibber==0.37.2 # homeassistant.components.dlink pyW215==0.8.0 From cf092c63c00e2e13a1c4e8bd4b5f026054a82ee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Fri, 24 Apr 2026 09:44:54 +0200 Subject: [PATCH 07/14] Declare PARALLEL_UPDATES = 0 for nobo_hub platforms (#169011) --- homeassistant/components/nobo_hub/climate.py | 2 ++ homeassistant/components/nobo_hub/select.py | 2 ++ homeassistant/components/nobo_hub/sensor.py | 2 ++ 3 files changed, 6 insertions(+) diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index b0228ea310a95..b418a1a6e1525 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -34,6 +34,8 @@ ) from .entity import NoboBaseEntity +PARALLEL_UPDATES = 0 + SUPPORT_FLAGS = ( ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py index adc6b5a8f8f85..4512f95892b15 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -23,6 +23,8 @@ ) from .entity import NoboBaseEntity +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py index 9d92413231ab8..642de720b8b67 100644 --- a/homeassistant/components/nobo_hub/sensor.py +++ b/homeassistant/components/nobo_hub/sensor.py @@ -19,6 +19,8 @@ from .const import ATTR_SERIAL, ATTR_ZONE_ID, DOMAIN, NOBO_MANUFACTURER from .entity import NoboBaseEntity +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, From ac6e4257483f1657a39957f02dd37ab26f09638c Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 24 Apr 2026 09:45:13 +0200 Subject: [PATCH 08/14] Add tilt and rotation binary sensors for Shelly Cury (#169002) Co-authored-by: Copilot --- .../components/shelly/binary_sensor.py | 22 ++++ homeassistant/components/shelly/strings.json | 6 ++ .../shelly/snapshots/test_devices.ambr | 102 ++++++++++++++++++ tests/components/shelly/test_binary_sensor.py | 47 ++++++++ 4 files changed, 177 insertions(+) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 632e5277de5f2..51bf7786233b7 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -350,6 +350,28 @@ def __init__( device_class=BinarySensorDeviceClass.OCCUPANCY, entity_class=RpcPresenceBinarySensor, ), + "cury_tilt": RpcBinarySensorDescription( + key="cury", + sub_key="errors", + translation_key="tilt", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda status, _: ( + False if status is None else "orientation_tilt" in status + ), + supported=lambda status: status.get("slots") is not None, + ), + "cury_rotation": RpcBinarySensorDescription( + key="cury", + sub_key="errors", + translation_key="rotation", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda status, _: ( + False if status is None else "orientation_plug_rotated" in status + ), + supported=lambda status: status.get("slots") is not None, + ), } diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 1a2fc3513f6f5..7312bf14a5034 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -211,8 +211,14 @@ "restart_required": { "name": "Restart required" }, + "rotation": { + "name": "Rotation" + }, "smoke_with_channel_name": { "name": "{channel_name} smoke" + }, + "tilt": { + "name": "Tilt" } }, "button": { diff --git a/tests/components/shelly/snapshots/test_devices.ambr b/tests/components/shelly/snapshots/test_devices.ambr index 83ea4a988bfe3..24b3175f2e056 100644 --- a/tests/components/shelly/snapshots/test_devices.ambr +++ b/tests/components/shelly/snapshots/test_devices.ambr @@ -101,6 +101,108 @@ 'state': 'off', }) # --- +# name: test_device[cury_gen4][binary_sensor.test_name_rotation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_name_rotation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Rotation', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rotation', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rotation', + 'unique_id': '123456789ABC-cury:0-cury_rotation', + 'unit_of_measurement': None, + }) +# --- +# name: test_device[cury_gen4][binary_sensor.test_name_rotation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Rotation', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_rotation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_device[cury_gen4][binary_sensor.test_name_tilt-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_name_tilt', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Tilt', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tilt', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tilt', + 'unique_id': '123456789ABC-cury:0-cury_tilt', + 'unit_of_measurement': None, + }) +# --- +# name: test_device[cury_gen4][binary_sensor.test_name_tilt-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Tilt', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_tilt', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_device[cury_gen4][button.test_name_restart-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index 9ce7ecd77df80..695e0a11eb23f 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -5,6 +5,7 @@ from aioshelly.const import ( MODEL_BLU_GATEWAY_G3, + MODEL_CURY_G4, MODEL_FLOOD_G4, MODEL_MOTION, MODEL_PLUS_SMOKE, @@ -788,3 +789,49 @@ async def test_migrate_unique_id_virtual_components_roles( assert ( "Migrating unique_id for binary_sensor.test_name_test_sensor" in caplog.text ) == (old_id != new_id) + + +async def test_rpc_cury_orientation_errors( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test RPC cury orientation error entities.""" + status = { + "cury:0": { + "id": 0, + "slots": { + "left": { + "intensity": 70, + "on": True, + "vial": {"level": 27, "name": "Forest Dream"}, + }, + "right": { + "intensity": 70, + "on": False, + "vial": {"level": 84, "name": "Velvet Rose"}, + }, + }, + } + } + monkeypatch.setattr(mock_rpc_device, "status", status) + await init_integration(hass, 4, model=MODEL_CURY_G4) + + entity_tilt = f"{BINARY_SENSOR_DOMAIN}.test_name_tilt" + entity_rotation = f"{BINARY_SENSOR_DOMAIN}.test_name_rotation" + + assert (state := hass.states.get(entity_tilt)) + assert state.state == STATE_OFF + + assert (state := hass.states.get(entity_rotation)) + assert state.state == STATE_OFF + + status["cury:0"]["errors"] = ["orientation_tilt", "orientation_plug_rotated"] + monkeypatch.setattr(mock_rpc_device, "status", status) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_tilt)) + assert state.state == STATE_ON + + assert (state := hass.states.get(entity_rotation)) + assert state.state == STATE_ON From eb825796f9a1c72dea40860be961846a38544c33 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:47:03 +0200 Subject: [PATCH 09/14] Remove name from config flow of Notifications for Android TV /Fire TV (#169024) --- homeassistant/components/nfandroidtv/__init__.py | 4 ++-- .../components/nfandroidtv/config_flow.py | 15 ++++----------- tests/components/nfandroidtv/__init__.py | 14 ++++---------- 3 files changed, 10 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/nfandroidtv/__init__.py b/homeassistant/components/nfandroidtv/__init__.py index 9a2e7da2b0a18..aae4b9d43c323 100644 --- a/homeassistant/components/nfandroidtv/__init__.py +++ b/homeassistant/components/nfandroidtv/__init__.py @@ -1,7 +1,7 @@ """The NFAndroidTV integration.""" from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType @@ -28,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, Platform.NOTIFY, DOMAIN, - dict(entry.data), + {CONF_NAME: entry.title, **entry.data}, hass.data[DATA_HASS_CONFIG], ) ) diff --git a/homeassistant/components/nfandroidtv/config_flow.py b/homeassistant/components/nfandroidtv/config_flow.py index ccb882509f6ea..9a5e420bbdce2 100644 --- a/homeassistant/components/nfandroidtv/config_flow.py +++ b/homeassistant/components/nfandroidtv/config_flow.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.const import CONF_HOST from .const import DEFAULT_NAME, DOMAIN @@ -26,24 +26,17 @@ async def async_step_user( errors = {} if user_input is not None: - self._async_abort_entries_match( - {CONF_HOST: user_input[CONF_HOST], CONF_NAME: user_input[CONF_NAME]} - ) + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) if not (error := await self._async_try_connect(user_input[CONF_HOST])): return self.async_create_entry( - title=user_input[CONF_NAME], + title=f"{DEFAULT_NAME} ({user_input[CONF_HOST]})", data=user_input, ) errors["base"] = error return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Required(CONF_NAME, default=DEFAULT_NAME): str, - } - ), + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), errors=errors, ) diff --git a/tests/components/nfandroidtv/__init__.py b/tests/components/nfandroidtv/__init__.py index 056e2b2bc716e..e4b6b8b599266 100644 --- a/tests/components/nfandroidtv/__init__.py +++ b/tests/components/nfandroidtv/__init__.py @@ -2,20 +2,14 @@ from unittest.mock import AsyncMock, patch -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.const import CONF_HOST HOST = "1.2.3.4" -NAME = "Android TV / Fire TV" +NAME = "Android TV / Fire TV (1.2.3.4)" -CONF_DATA = { - CONF_HOST: HOST, - CONF_NAME: NAME, -} +CONF_DATA = {CONF_HOST: HOST} -CONF_CONFIG_FLOW = { - CONF_HOST: HOST, - CONF_NAME: NAME, -} +CONF_CONFIG_FLOW = {CONF_HOST: HOST} async def _create_mocked_tv(raise_exception=False): From 1942f12e55be594366a45fb47d7dd23d3f6019c0 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Fri, 24 Apr 2026 10:58:16 +0300 Subject: [PATCH 10/14] Refactor Anthropic model args (#169014) --- homeassistant/components/anthropic/entity.py | 69 +++++++++---------- .../anthropic/snapshots/test_ai_task.ambr | 2 +- 2 files changed, 34 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index 78da8a4db2489..792c6e8d31d44 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -703,15 +703,14 @@ def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> Non entry_type=dr.DeviceEntryType.SERVICE, ) - async def _async_handle_chat_log( # noqa: C901 + async def _get_model_args( # noqa: C901 self, chat_log: conversation.ChatLog, structure_name: str | None = None, structure: vol.Schema | None = None, - max_iterations: int = MAX_TOOL_ITERATIONS, - ) -> None: - """Generate an answer for the chat log.""" - options = self.subentry.data + ) -> tuple[MessageCreateParamsStreaming, str | None]: + """Get the model arguments.""" + options: dict[str, Any] = DEFAULT | self.subentry.data preloaded_tools = [ "HassTurnOn", @@ -729,21 +728,18 @@ async def _async_handle_chat_log( # noqa: C901 messages, container_id = _convert_content(chat_log.content[1:]) - model = options.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL]) + model = options[CONF_CHAT_MODEL] model_args = MessageCreateParamsStreaming( model=model, messages=messages, - max_tokens=options.get(CONF_MAX_TOKENS, DEFAULT[CONF_MAX_TOKENS]), + max_tokens=options[CONF_MAX_TOKENS], system=system.content, stream=True, container=container_id, ) - if ( - options.get(CONF_PROMPT_CACHING, DEFAULT[CONF_PROMPT_CACHING]) - == PromptCaching.PROMPT - ): + if options[CONF_PROMPT_CACHING] == PromptCaching.PROMPT: model_args["system"] = [ { "type": "text", @@ -751,19 +747,14 @@ async def _async_handle_chat_log( # noqa: C901 "cache_control": {"type": "ephemeral"}, } ] - elif ( - options.get(CONF_PROMPT_CACHING, DEFAULT[CONF_PROMPT_CACHING]) - == PromptCaching.AUTOMATIC - ): + elif options[CONF_PROMPT_CACHING] == PromptCaching.AUTOMATIC: model_args["cache_control"] = {"type": "ephemeral"} if ( self.model_info.capabilities and self.model_info.capabilities.thinking.types.adaptive.supported ): - thinking_effort = options.get( - CONF_THINKING_EFFORT, DEFAULT[CONF_THINKING_EFFORT] - ) + thinking_effort = options[CONF_THINKING_EFFORT] if thinking_effort != "none": model_args["thinking"] = ThinkingConfigAdaptiveParam( type="adaptive", display="summarized" @@ -772,9 +763,7 @@ async def _async_handle_chat_log( # noqa: C901 else: model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled") else: - thinking_budget = options.get( - CONF_THINKING_BUDGET, DEFAULT[CONF_THINKING_BUDGET] - ) + thinking_budget = options[CONF_THINKING_BUDGET] if ( self.model_info.capabilities and self.model_info.capabilities.thinking.types.enabled.supported @@ -791,9 +780,7 @@ async def _async_handle_chat_log( # noqa: C901 and self.model_info.capabilities.effort.supported ): model_args["output_config"] = OutputConfigParam( - effort=options.get( - CONF_THINKING_EFFORT, DEFAULT[CONF_THINKING_EFFORT] - ) + effort=options[CONF_THINKING_EFFORT] ) tools: list[ToolUnionParam] = [] @@ -803,12 +790,12 @@ async def _async_handle_chat_log( # noqa: C901 for tool in chat_log.llm_api.tools ] - if options.get(CONF_CODE_EXECUTION): + if options[CONF_CODE_EXECUTION]: # The `web_search_20260209` tool automatically enables `code_execution_20260120` tool if ( not self.model_info.capabilities or not self.model_info.capabilities.code_execution.supported - or not options.get(CONF_WEB_SEARCH) + or not options[CONF_WEB_SEARCH] ): tools.append( CodeExecutionTool20250825Param( @@ -817,26 +804,26 @@ async def _async_handle_chat_log( # noqa: C901 ), ) - if options.get(CONF_WEB_SEARCH): + if options[CONF_WEB_SEARCH]: if ( not self.model_info.capabilities or not self.model_info.capabilities.code_execution.supported - or not options.get(CONF_CODE_EXECUTION) + or not options[CONF_CODE_EXECUTION] ): web_search: WebSearchTool20250305Param | WebSearchTool20260209Param = ( WebSearchTool20250305Param( name="web_search", type="web_search_20250305", - max_uses=options.get(CONF_WEB_SEARCH_MAX_USES), + max_uses=options[CONF_WEB_SEARCH_MAX_USES], ) ) else: web_search = WebSearchTool20260209Param( name="web_search", type="web_search_20260209", - max_uses=options.get(CONF_WEB_SEARCH_MAX_USES), + max_uses=options[CONF_WEB_SEARCH_MAX_USES], ) - if options.get(CONF_WEB_SEARCH_USER_LOCATION): + if options[CONF_WEB_SEARCH_USER_LOCATION]: web_search["user_location"] = { "type": "approximate", "city": options.get(CONF_WEB_SEARCH_CITY, ""), @@ -937,10 +924,7 @@ async def _async_handle_chat_log( # noqa: C901 preloaded_tools.append(structure_name) if tools: - if ( - options.get(CONF_TOOL_SEARCH, DEFAULT[CONF_TOOL_SEARCH]) - and len(tools) > len(preloaded_tools) + 1 - ): + if options[CONF_TOOL_SEARCH] and len(tools) > len(preloaded_tools) + 1: for tool in tools: if not tool["name"].endswith(tuple(preloaded_tools)): tool["defer_loading"] = True @@ -953,6 +937,19 @@ async def _async_handle_chat_log( # noqa: C901 model_args["tools"] = tools + return model_args, structure_name + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + structure_name: str | None = None, + structure: vol.Schema | None = None, + max_iterations: int = MAX_TOOL_ITERATIONS, + ) -> None: + """Generate an answer for the chat log.""" + model_args, structure_name = await self._get_model_args( + chat_log, structure_name, structure + ) coordinator = self.entry.runtime_data client = coordinator.client @@ -974,7 +971,7 @@ async def _async_handle_chat_log( # noqa: C901 ) ] ) - messages.extend(new_messages) + cast(list[MessageParam], model_args["messages"]).extend(new_messages) except anthropic.AuthenticationError as err: # Trigger coordinator to confirm the auth failure and trigger the reauth flow. await coordinator.async_request_refresh() diff --git a/tests/components/anthropic/snapshots/test_ai_task.ambr b/tests/components/anthropic/snapshots/test_ai_task.ambr index 6c9ed06376c16..218c894ee559c 100644 --- a/tests/components/anthropic/snapshots/test_ai_task.ambr +++ b/tests/components/anthropic/snapshots/test_ai_task.ambr @@ -295,7 +295,7 @@ }), 'tools': list([ dict({ - 'max_uses': None, + 'max_uses': 5, 'name': 'web_search', 'type': 'web_search_20250305', }), From 39fbd2ccbdd6e8bbe5aad835b24e9dbfd4b76068 Mon Sep 17 00:00:00 2001 From: "A. Gideonse" Date: Fri, 24 Apr 2026 09:58:59 +0200 Subject: [PATCH 11/14] Bump indevolt-api to 1.3.1 (#168986) --- homeassistant/components/indevolt/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/indevolt/manifest.json b/homeassistant/components/indevolt/manifest.json index 2e67b487bd60d..33d0a3d360ad5 100644 --- a/homeassistant/components/indevolt/manifest.json +++ b/homeassistant/components/indevolt/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["indevolt-api==1.2.3"] + "requirements": ["indevolt-api==1.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8150f9152892b..50db74a453add 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1329,7 +1329,7 @@ imgw_pib==2.1.1 incomfort-client==0.7.0 # homeassistant.components.indevolt -indevolt-api==1.2.3 +indevolt-api==1.3.1 # homeassistant.components.influxdb influxdb-client==1.50.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 35e651d15a4fd..5b031f5cef31f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1181,7 +1181,7 @@ imgw_pib==2.1.1 incomfort-client==0.7.0 # homeassistant.components.indevolt -indevolt-api==1.2.3 +indevolt-api==1.3.1 # homeassistant.components.influxdb influxdb-client==1.50.0 From aec8d00c95212025865833d20b632d10442c3dc2 Mon Sep 17 00:00:00 2001 From: Trendafil Gechev Date: Fri, 24 Apr 2026 11:06:49 +0300 Subject: [PATCH 12/14] Add WLED segment freeze support (#168424) --- homeassistant/components/wled/icons.json | 12 ++ homeassistant/components/wled/strings.json | 6 + homeassistant/components/wled/switch.py | 98 +++++++++--- tests/components/wled/fixtures/rgb.json | 2 +- .../wled/snapshots/test_diagnostics.ambr | 2 +- .../wled/snapshots/test_switch.ambr | 150 ++++++++++++++++++ tests/components/wled/test_switch.py | 46 ++++-- 7 files changed, 285 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/wled/icons.json b/homeassistant/components/wled/icons.json index a4e8fa1092a84..3cbed36792b0b 100644 --- a/homeassistant/components/wled/icons.json +++ b/homeassistant/components/wled/icons.json @@ -51,12 +51,24 @@ } }, "switch": { + "freeze": { + "default": "mdi:timer", + "state": { + "on": "mdi:eye" + } + }, "nightlight": { "default": "mdi:weather-night" }, "reverse": { "default": "mdi:swap-horizontal-bold" }, + "segment_freeze": { + "default": "mdi:timer", + "state": { + "on": "mdi:eye" + } + }, "segment_reverse": { "default": "mdi:swap-horizontal-bold" }, diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index aa4303c670941..2329636d068be 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -115,12 +115,18 @@ } }, "switch": { + "freeze": { + "name": "Freeze" + }, "nightlight": { "name": "Nightlight" }, "reverse": { "name": "Reverse" }, + "segment_freeze": { + "name": "Segment {segment} freeze" + }, "segment_reverse": { "name": "Segment {segment} reverse" }, diff --git a/homeassistant/components/wled/switch.py b/homeassistant/components/wled/switch.py index 1e228b0a91ede..e18a32381f994 100644 --- a/homeassistant/components/wled/switch.py +++ b/homeassistant/components/wled/switch.py @@ -2,10 +2,14 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable +from dataclasses import dataclass from functools import partial from typing import Any -from homeassistant.components.switch import SwitchEntity +from wled import WLED + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -18,6 +22,36 @@ PARALLEL_UPDATES = 1 +@dataclass(frozen=True, kw_only=True) +class WLEDSegmentSwitchEntityDescription(SwitchEntityDescription): + """Describes WLED segment switch entity.""" + + segment_translation_key: str + set_segment: Callable[[WLED, int, bool], Awaitable[None]] + + +SEGMENT_SWITCHES: tuple[WLEDSegmentSwitchEntityDescription, ...] = ( + WLEDSegmentSwitchEntityDescription( + key="reverse", + translation_key="reverse", + segment_translation_key="segment_reverse", + set_segment=lambda wled, segment, value: wled.segment( + segment_id=segment, + reverse=value, + ), + ), + WLEDSegmentSwitchEntityDescription( + key="freeze", + translation_key="freeze", + segment_translation_key="segment_freeze", + set_segment=lambda wled, segment, value: wled.segment( + segment_id=segment, + freeze=value, + ), + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: WLEDConfigEntry, @@ -144,25 +178,35 @@ async def async_turn_on(self, **kwargs: Any) -> None: await self.coordinator.wled.sync(receive=True) -class WLEDReverseSwitch(WLEDEntity, SwitchEntity): - """Defines a WLED reverse effect switch.""" +class WLEDSegmentSwitch(WLEDEntity, SwitchEntity): + """Defines a WLED segment switch.""" + entity_description: WLEDSegmentSwitchEntityDescription _attr_entity_category = EntityCategory.CONFIG - _attr_translation_key = "reverse" - _segment: int - def __init__(self, coordinator: WLEDDataUpdateCoordinator, segment: int) -> None: - """Initialize WLED reverse effect switch.""" + def __init__( + self, + coordinator: WLEDDataUpdateCoordinator, + segment: int, + description: WLEDSegmentSwitchEntityDescription, + ) -> None: + """Initialize WLED segment switch.""" super().__init__(coordinator=coordinator) + self.entity_description = description + self._segment = segment + # Segment 0 uses a simpler name, which is more natural for when using # a single segment / using WLED with one big LED strip. if segment != 0: - self._attr_translation_key = "segment_reverse" + self._attr_translation_key = description.segment_translation_key self._attr_translation_placeholders = {"segment": str(segment)} + else: + self._attr_translation_key = description.translation_key - self._attr_unique_id = f"{coordinator.data.info.mac_address}_reverse_{segment}" - self._segment = segment + self._attr_unique_id = ( + f"{coordinator.data.info.mac_address}_{description.key}_{segment}" + ) @property def available(self) -> bool: @@ -174,17 +218,26 @@ def available(self) -> bool: @property def is_on(self) -> bool: """Return the state of the switch.""" - return self.coordinator.data.state.segments[self._segment].reverse + segment = self.coordinator.data.state.segments[self._segment] + return bool(getattr(segment, self.entity_description.key)) + + async def _async_set_state(self, value: bool) -> None: + """Set segment state.""" + await self.entity_description.set_segment( + self.coordinator.wled, + self._segment, + value, + ) @wled_exception_handler - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off the WLED reverse effect switch.""" - await self.coordinator.wled.segment(segment_id=self._segment, reverse=False) + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the WLED segment switch.""" + await self._async_set_state(True) @wled_exception_handler - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on the WLED reverse effect switch.""" - await self.coordinator.wled.segment(segment_id=self._segment, reverse=True) + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the WLED segment switch.""" + await self._async_set_state(False) @callback @@ -200,11 +253,18 @@ def async_update_segments( if segment.segment_id is not None } - new_entities: list[WLEDReverseSwitch] = [] + new_entities: list[WLEDSegmentSwitch] = [] # Process new segments, add them to Home Assistant for segment_id in segment_ids - current_ids: current_ids.add(segment_id) - new_entities.append(WLEDReverseSwitch(coordinator, segment_id)) + new_entities.extend( + WLEDSegmentSwitch( + coordinator=coordinator, + segment=segment_id, + description=description, + ) + for description in SEGMENT_SWITCHES + ) async_add_entities(new_entities) diff --git a/tests/components/wled/fixtures/rgb.json b/tests/components/wled/fixtures/rgb.json index 50a82eb792e5d..085410752b813 100644 --- a/tests/components/wled/fixtures/rgb.json +++ b/tests/components/wled/fixtures/rgb.json @@ -64,7 +64,7 @@ "spc": 0, "of": 0, "on": true, - "frz": false, + "frz": true, "bri": 255, "cct": 127, "set": 0, diff --git a/tests/components/wled/snapshots/test_diagnostics.ambr b/tests/components/wled/snapshots/test_diagnostics.ambr index 44ce1833e44e7..853e10c8f56bb 100644 --- a/tests/components/wled/snapshots/test_diagnostics.ambr +++ b/tests/components/wled/snapshots/test_diagnostics.ambr @@ -399,7 +399,7 @@ 0, ]), ]), - 'frz': False, + 'frz': True, 'fx': 3, 'grp': 1, 'id': 1, diff --git a/tests/components/wled/snapshots/test_switch.ambr b/tests/components/wled/snapshots/test_switch.ambr index be36aa560a362..192361da872c9 100644 --- a/tests/components/wled/snapshots/test_switch.ambr +++ b/tests/components/wled/snapshots/test_switch.ambr @@ -151,6 +151,106 @@ 'state': 'on', }) # --- +# name: test_snapshots[rgb][switch.wled_rgb_light_freeze-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.wled_rgb_light_freeze', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Freeze', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Freeze', + 'platform': 'wled', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'freeze', + 'unique_id': 'aabbccddeeff_freeze_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_snapshots[rgb][switch.wled_rgb_light_freeze-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGB Light Freeze', + }), + 'context': , + 'entity_id': 'switch.wled_rgb_light_freeze', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_snapshots[rgb][switch.wled_rgb_light_segment_1_freeze-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.wled_rgb_light_segment_1_freeze', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Segment 1 freeze', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Segment 1 freeze', + 'platform': 'wled', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'segment_freeze', + 'unique_id': 'aabbccddeeff_freeze_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_snapshots[rgb][switch.wled_rgb_light_segment_1_freeze-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGB Light Segment 1 freeze', + }), + 'context': , + 'entity_id': 'switch.wled_rgb_light_segment_1_freeze', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_snapshots[rgb][switch.wled_rgb_light_sync_receive-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -355,6 +455,56 @@ 'state': 'off', }) # --- +# name: test_snapshots[rgb_single_segment][switch.wled_rgb_light_freeze-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.wled_rgb_light_freeze', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Freeze', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Freeze', + 'platform': 'wled', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'freeze', + 'unique_id': 'aabbccddeeff_freeze_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_snapshots[rgb_single_segment][switch.wled_rgb_light_freeze-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGB Light Freeze', + }), + 'context': , + 'entity_id': 'switch.wled_rgb_light_freeze', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_snapshots[rgb_single_segment][switch.wled_rgb_light_sync_receive-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/wled/test_switch.py b/tests/components/wled/test_switch.py index d9088b8b59396..7d834c09efd5c 100644 --- a/tests/components/wled/test_switch.py +++ b/tests/components/wled/test_switch.py @@ -60,6 +60,12 @@ async def test_snapshots( {"on": True}, {"on": False}, ), + ( + "switch.wled_rgb_light_freeze", + "segment", + {"segment_id": 0, "freeze": True}, + {"segment_id": 0, "freeze": False}, + ), ( "switch.wled_rgb_light_reverse", "segment", @@ -152,8 +158,11 @@ async def test_switch_dynamically_handle_segments( ) -> None: """Test if a new/deleted segment is dynamically added/removed.""" - assert (segment0 := hass.states.get("switch.wled_rgb_light_reverse")) - assert segment0.state == STATE_OFF + assert (segment0_freeze := hass.states.get("switch.wled_rgb_light_freeze")) + assert segment0_freeze.state == STATE_OFF + assert (segment0_reverse := hass.states.get("switch.wled_rgb_light_reverse")) + assert segment0_reverse.state == STATE_OFF + assert not hass.states.get("switch.wled_rgb_light_segment_1_freeze") assert not hass.states.get("switch.wled_rgb_light_segment_1_reverse") # Test adding a segment dynamically... @@ -166,10 +175,18 @@ async def test_switch_dynamically_handle_segments( async_fire_time_changed(hass) await hass.async_block_till_done() - assert (segment0 := hass.states.get("switch.wled_rgb_light_reverse")) - assert segment0.state == STATE_OFF - assert (segment1 := hass.states.get("switch.wled_rgb_light_segment_1_reverse")) - assert segment1.state == STATE_ON + assert (segment0_freeze := hass.states.get("switch.wled_rgb_light_freeze")) + assert segment0_freeze.state == STATE_OFF + assert (segment0_reverse := hass.states.get("switch.wled_rgb_light_reverse")) + assert segment0_reverse.state == STATE_OFF + assert ( + segment1_freeze := hass.states.get("switch.wled_rgb_light_segment_1_freeze") + ) + assert segment1_freeze.state == STATE_ON + assert ( + segment1_reverse := hass.states.get("switch.wled_rgb_light_segment_1_reverse") + ) + assert segment1_reverse.state == STATE_ON # Test remove segment again... mock_wled.update.return_value = return_value @@ -177,7 +194,16 @@ async def test_switch_dynamically_handle_segments( async_fire_time_changed(hass) await hass.async_block_till_done() - assert (segment0 := hass.states.get("switch.wled_rgb_light_reverse")) - assert segment0.state == STATE_OFF - assert (segment1 := hass.states.get("switch.wled_rgb_light_segment_1_reverse")) - assert segment1.state == STATE_UNAVAILABLE + assert (segment0_freeze := hass.states.get("switch.wled_rgb_light_freeze")) + assert segment0_freeze.state == STATE_OFF + assert (segment0_reverse := hass.states.get("switch.wled_rgb_light_reverse")) + assert segment0_reverse.state == STATE_OFF + + assert ( + segment1_freeze := hass.states.get("switch.wled_rgb_light_segment_1_freeze") + ) + assert segment1_freeze.state == STATE_UNAVAILABLE + assert ( + segment1_reverse := hass.states.get("switch.wled_rgb_light_segment_1_reverse") + ) + assert segment1_reverse.state == STATE_UNAVAILABLE From dbb750a583d69ae3954a315fc3b51df9063d3d38 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:07:26 +0200 Subject: [PATCH 13/14] Add AV1 support for HLS fallback (#161492) Co-authored-by: RaHehl Co-authored-by: Joost Lekkerkerker --- homeassistant/components/stream/worker.py | 28 +++++++++++++-- tests/components/stream/test_worker.py | 44 +++++++++++++++++++++-- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index f2d59c7e09050..4c0c559c8ad31 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -15,6 +15,7 @@ import av import av.audio +from av.codec.codec import UnknownCodecError # pylint: disable=no-name-in-module import av.container from av.container import InputContainer import av.stream @@ -152,6 +153,23 @@ def __init__( self._stream_state = stream_state self._start_time = dt_util.utcnow() + @staticmethod + def _add_stream_from_template( + container: av.container.OutputContainer, + template: av.stream.Stream, + ) -> av.stream.Stream: + """Add a stream to the output container from a template. + + Decoder-only codecs (e.g., libdav1d for AV1) have no matching + encoder, causing add_stream_from_template to fail. Retrying with + opaque=True bypasses the encoder lookup and copies codec parameters + directly from the template, which is sufficient for remuxing. + """ + try: + return container.add_stream_from_template(template) + except UnknownCodecError: + return container.add_stream_from_template(template, opaque=True) + def make_new_av( self, memory_file: BytesIO, @@ -223,7 +241,10 @@ def make_new_av( format=SEGMENT_CONTAINER_FORMAT, container_options=container_options, ) - output_vstream = container.add_stream_from_template(input_vstream) + output_vstream = cast( + av.VideoStream, + self._add_stream_from_template(container, input_vstream), + ) # Check if audio is requested output_astream = None if input_astream: @@ -231,7 +252,10 @@ def make_new_av( self._audio_bsf_context = av.BitStreamFilterContext( self._audio_bsf, input_astream ) - output_astream = container.add_stream_from_template(input_astream) + output_astream = cast( + av.audio.AudioStream, + self._add_stream_from_template(container, input_astream), + ) return container, output_vstream, output_astream def reset(self, video_dts: int) -> None: diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index 616bb1ed3187f..1d4a97a2cde32 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -20,9 +20,10 @@ import math from pathlib import Path import threading -from unittest.mock import patch +from unittest.mock import MagicMock, patch import av +from av.codec.codec import UnknownCodecError # pylint: disable=no-name-in-module import numpy as np import pytest @@ -44,6 +45,7 @@ from homeassistant.components.stream.exceptions import StreamClientError from homeassistant.components.stream.worker import ( StreamEndedError, + StreamMuxer, StreamState, StreamWorkerError, stream_worker, @@ -219,7 +221,7 @@ def __init__(self) -> None: self.video_packets = [] self.memory_file: io.BytesIO | None = None - def add_stream_from_template(self, template): + def add_stream_from_template(self, template, **kwargs): """Create an output buffer that captures packets for test to examine.""" class FakeAvOutputStream: @@ -1089,3 +1091,41 @@ async def test_get_image_rotated(hass: HomeAssistant, h264_video, filename) -> N 0 ][0] ).all() + + +def test_add_stream_from_template_happy_path() -> None: + """Test add_stream_from_template returns stream directly on success.""" + template = MagicMock(spec=av.VideoStream) + expected_stream = MagicMock(spec=av.VideoStream) + container = MagicMock() + container.add_stream_from_template.return_value = expected_stream + + result = StreamMuxer._add_stream_from_template(container, template) + + assert result is expected_stream + container.add_stream_from_template.assert_called_once_with(template) + + +def test_add_stream_from_template_decoder_only_fallback() -> None: + """Test decoder-only codecs fall back to opaque=True. + + When a video stream uses a decoder-only codec like libdav1d (AV1), + add_stream_from_template raises UnknownCodecError because no matching + encoder exists. The worker retries with opaque=True to bypass the + encoder lookup. + """ + template = MagicMock(spec=av.VideoStream) + expected_stream = MagicMock(spec=av.VideoStream) + container = MagicMock() + container.add_stream_from_template.side_effect = [ + UnknownCodecError("libdav1d"), + expected_stream, + ] + + result = StreamMuxer._add_stream_from_template(container, template) + + assert result is expected_stream + assert container.add_stream_from_template.call_args_list == [ + ((template,), {}), + ((template,), {"opaque": True}), + ] From 76376d6b2605c74071ce1a1405cc0fa81d38f62d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 24 Apr 2026 10:59:59 +0200 Subject: [PATCH 14/14] Add pylint plugin to detect IP-based unique IDs in config entries (#168822) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: frenck <195327+frenck@users.noreply.github.com> --- .../components/airtouch5/config_flow.py | 2 + homeassistant/components/dnsip/config_flow.py | 2 + .../components/freebox/config_flow.py | 2 + .../components/panasonic_viera/config_flow.py | 2 + .../components/rainmachine/config_flow.py | 5 +- ...ss_enforce_config_entry_unique_id_no_ip.py | 200 ++++++++++++++++++ pyproject.toml | 1 + tests/pylint/conftest.py | 21 ++ ...st_enforce_config_entry_unique_id_no_ip.py | 193 +++++++++++++++++ 9 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 pylint/plugins/hass_enforce_config_entry_unique_id_no_ip.py create mode 100644 tests/pylint/test_enforce_config_entry_unique_id_no_ip.py diff --git a/homeassistant/components/airtouch5/config_flow.py b/homeassistant/components/airtouch5/config_flow.py index 38c85e45fb867..72d20f5c49b11 100644 --- a/homeassistant/components/airtouch5/config_flow.py +++ b/homeassistant/components/airtouch5/config_flow.py @@ -36,6 +36,8 @@ async def async_step_user( _LOGGER.exception("Unexpected exception") errors = {"base": "cannot_connect"} else: + # Uses the host/IP value from CONF_HOST as unique ID, which is no longer allowed + # pylint: disable-next=hass-unique-id-ip-based await self.async_set_unique_id(user_input[CONF_HOST]) self._abort_if_unique_id_configured() return self.async_create_entry( diff --git a/homeassistant/components/dnsip/config_flow.py b/homeassistant/components/dnsip/config_flow.py index 0ea2a9d092b92..24c42408fd258 100644 --- a/homeassistant/components/dnsip/config_flow.py +++ b/homeassistant/components/dnsip/config_flow.py @@ -133,6 +133,8 @@ async def async_step_user( ): errors["base"] = "invalid_hostname" else: + # Uses hostname as unique ID, which is no longer allowed + # pylint: disable-next=hass-unique-id-ip-based await self.async_set_unique_id(hostname) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index 7ca26f7f34ee9..a7e0f4afc72b7 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -44,6 +44,8 @@ async def async_step_user( self._data = user_input # Check if already configured + # Uses the host/IP value from CONF_HOST as unique ID, which is no longer allowed + # pylint: disable-next=hass-unique-id-ip-based await self.async_set_unique_id(self._data[CONF_HOST]) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/panasonic_viera/config_flow.py b/homeassistant/components/panasonic_viera/config_flow.py index b00fee513a63e..58baccf0dcc7a 100644 --- a/homeassistant/components/panasonic_viera/config_flow.py +++ b/homeassistant/components/panasonic_viera/config_flow.py @@ -168,5 +168,7 @@ async def async_load_data(self, config: dict[str, Any]) -> None: self._data[CONF_PORT] = self._data.get(CONF_PORT, DEFAULT_PORT) self._data[CONF_ON_ACTION] = self._data.get(CONF_ON_ACTION) + # Uses the host/IP value from CONF_HOST as unique ID, which is no longer allowed + # pylint: disable-next=hass-unique-id-ip-based await self.async_set_unique_id(self._data[CONF_HOST]) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py index 6ce95d7e54700..0369590b7dbb2 100644 --- a/homeassistant/components/rainmachine/config_flow.py +++ b/homeassistant/components/rainmachine/config_flow.py @@ -102,7 +102,10 @@ async def async_step_homekit_zeroconf( # A new rain machine: We will change out the unique id # for the mac address once we authenticate, however we want to # prevent multiple different rain machines on the same network - # from being shown in discovery + # from being shown in discovery. + # Uses the discovered IP address as a temporary unique ID for + # discovery de-duplication until the MAC address is available. + # pylint: disable-next=hass-unique-id-ip-based await self.async_set_unique_id(ip_address) self._abort_if_unique_id_configured() self.discovered_ip_address = ip_address diff --git a/pylint/plugins/hass_enforce_config_entry_unique_id_no_ip.py b/pylint/plugins/hass_enforce_config_entry_unique_id_no_ip.py new file mode 100644 index 0000000000000..c0c41adc2f7a9 --- /dev/null +++ b/pylint/plugins/hass_enforce_config_entry_unique_id_no_ip.py @@ -0,0 +1,200 @@ +"""Plugin for detecting IP-based unique IDs in config entries. + +Using an IP address or hostname as a config entry's ``unique_id`` breaks when +the device gets a new DHCP lease. The unique ID must be something stable -- a +MAC address (formatted via ``format_mac``), serial number, or other hardware +identifier. + +IP/hostname-based unique IDs were the #1 unique ID review pattern, found in +16.2% of new-integration PRs across 1,100+ analyzed PRs. +""" + +from __future__ import annotations + +from astroid import nodes +from pylint.checkers import BaseChecker +from pylint.lint import PyLinter + +# Config keys that represent host/IP -- used as unique ID sources. +_IP_HOST_NAMES: frozenset[str] = frozenset( + { + "CONF_HOST", + "CONF_IP_ADDRESS", + "CONF_URL", + "host", + "hostname", + "ip", + "ip_address", + } +) + + +class HassEnforceConfigEntryUniqueIdNoIpChecker(BaseChecker): + """Checker for IP/hostname-based unique IDs. + + Detects ``async_set_unique_id`` calls where the argument directly + references an IP/hostname config key, or where the argument is a + variable that was assigned from an IP/hostname source within the + same function. + """ + + name = "hass_enforce_config_entry_unique_id_no_ip" + priority = -1 + msgs = { + "W7491": ( + "unique_id should not be based on '%s' -- IP addresses change; " + "use a MAC address (format_mac), serial number, or hardware ID " + "(https://developers.home-assistant.io/docs/core/" + "integration-quality-scale/rules/unique-config-entry)", + "hass-unique-id-ip-based", + "Used when a unique_id is set from a host/IP config key. IP " + "addresses change when devices get new DHCP leases. Use a " + "stable hardware identifier instead. " + "See https://developers.home-assistant.io/docs/core/" + "integration-quality-scale/rules/unique-config-entry", + ), + } + options = () + + def visit_call(self, node: nodes.Call) -> None: + """Check async_set_unique_id(host-based-value) calls.""" + root_name = node.root().name + if not root_name.startswith("homeassistant.components."): + return + + parts = root_name.split(".") + current_module = parts[3] if len(parts) > 3 else "" + if current_module != "config_flow": + return + + if not isinstance(node.func, nodes.Attribute): + return + if node.func.attrname != "async_set_unique_id": + return + + unique_id_node: nodes.NodeNG | None = None + if node.args: + unique_id_node = node.args[0] + elif node.keywords: + for keyword in node.keywords: + if keyword.arg == "unique_id": + unique_id_node = keyword.value + break + + if unique_id_node is None: + return + + # Check the argument directly first + ref = _value_references_ip(unique_id_node) + + # If the argument is a plain variable, resolve it by scanning + # assignments in the enclosing function + if ref is None and isinstance(unique_id_node, nodes.Name): + ref = _resolve_variable_ip_ref(unique_id_node) + + if ref: + self.add_message( + "hass-unique-id-ip-based", + node=node, + args=(ref,), + ) + + +def _value_references_ip(node: nodes.NodeNG) -> str | None: + """Return the IP/host reference name if the expression looks IP-based. + + Checks for: + - Direct Name reference: ``CONF_HOST`` + - Subscript: ``data[CONF_HOST]``, ``user_input["host"]`` + - Call: ``data.get(CONF_HOST)`` or ``data.get("host")`` + - Embedded references: ``f"prefix_{data[CONF_HOST]}"`` + """ + # Direct name: CONF_HOST (only at top level, not recursively, + # to avoid matching local variables like `host` in `host.api.mac`) + if isinstance(node, nodes.Name) and node.name in _IP_HOST_NAMES: + return str(node.name) + + return _check_subscript_or_call_ip(node) + + +def _check_subscript_or_call_ip(node: nodes.NodeNG) -> str | None: + """Check for IP references in subscript/call patterns, recursively. + + Unlike ``_value_references_ip``, this does NOT match bare Name nodes, + avoiding false positives from local variables named ``host`` used in + attribute chains like ``host.api.mac_address``. + """ + # Subscript: data[CONF_HOST] or data["host"] + if isinstance(node, nodes.Subscript): + key = node.slice + if isinstance(key, nodes.Name) and key.name in _IP_HOST_NAMES: + return str(key.name) + if ( + isinstance(key, nodes.Const) + and isinstance(key.value, str) + and key.value in _IP_HOST_NAMES + ): + return key.value + + # Call: data.get(CONF_HOST) or data.get("host") + if ( + isinstance(node, nodes.Call) + and isinstance(node.func, nodes.Attribute) + and node.func.attrname == "get" + and node.args + ): + first_arg = node.args[0] + if isinstance(first_arg, nodes.Name) and first_arg.name in _IP_HOST_NAMES: + return str(first_arg.name) + if ( + isinstance(first_arg, nodes.Const) + and isinstance(first_arg.value, str) + and first_arg.value in _IP_HOST_NAMES + ): + return first_arg.value + + # Recurse into child nodes to catch embedded references (e.g. f-strings), + # but skip function call arguments -- a call like get_unique_id(host) + # transforms the value, so the result isn't IP-based. + if not isinstance(node, nodes.Call): + for child in node.get_children(): + ref = _check_subscript_or_call_ip(child) + if ref: + return ref + + return None + + +def _resolve_variable_ip_ref(name_node: nodes.Name) -> str | None: + """Resolve a variable back to its assignments in the enclosing function. + + If any assignment to the variable in the same function references an + IP/host source, return the reference name. + """ + # Find the enclosing function + current = name_node.parent + while current is not None: + if isinstance(current, nodes.FunctionDef): + break + current = current.parent + else: + return None + + # Scan all assignments in the function for this variable name + for assign in current.nodes_of_class(nodes.Assign): + for target in assign.targets: + if ( + isinstance(target, nodes.AssignName) + and target.name == name_node.name + and assign.value + ): + ref = _check_subscript_or_call_ip(assign.value) + if ref: + return ref + + return None + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(HassEnforceConfigEntryUniqueIdNoIpChecker(linter)) diff --git a/pyproject.toml b/pyproject.toml index ef02514e301d1..1acdb0c1a5d91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,6 +121,7 @@ load-plugins = [ "hass_async_load_fixtures", "hass_decorator", "hass_enforce_class_module", + "hass_enforce_config_entry_unique_id_no_ip", "hass_enforce_config_flow_no_polling", "hass_enforce_greek_micro_char", "hass_enforce_runtime_data", diff --git a/tests/pylint/conftest.py b/tests/pylint/conftest.py index 4ca019bb60e5c..bb5cb073d55ce 100644 --- a/tests/pylint/conftest.py +++ b/tests/pylint/conftest.py @@ -140,6 +140,27 @@ def decorator_checker_fixture(hass_decorator, linter) -> BaseChecker: return type_hint_checker +@pytest.fixture(name="hass_enforce_config_entry_unique_id_no_ip", scope="package") +def hass_enforce_config_entry_unique_id_no_ip_fixture() -> ModuleType: + """Fixture to the content for the unique_id_no_ip check.""" + return _load_plugin_from_file( + "hass_enforce_config_entry_unique_id_no_ip", + "pylint/plugins/hass_enforce_config_entry_unique_id_no_ip.py", + ) + + +@pytest.fixture(name="enforce_config_entry_unique_id_no_ip_checker") +def enforce_config_entry_unique_id_no_ip_checker_fixture( + hass_enforce_config_entry_unique_id_no_ip, linter +) -> BaseChecker: + """Fixture to provide a unique_id_no_ip checker.""" + checker = hass_enforce_config_entry_unique_id_no_ip.HassEnforceConfigEntryUniqueIdNoIpChecker( + linter + ) + checker.module = "homeassistant.components.pylint_test" + return checker + + @pytest.fixture(name="hass_enforce_config_flow_no_polling", scope="package") def hass_enforce_config_flow_no_polling_fixture() -> ModuleType: """Fixture to the content for the config_flow_no_polling check.""" diff --git a/tests/pylint/test_enforce_config_entry_unique_id_no_ip.py b/tests/pylint/test_enforce_config_entry_unique_id_no_ip.py new file mode 100644 index 0000000000000..2d8c508ba649c --- /dev/null +++ b/tests/pylint/test_enforce_config_entry_unique_id_no_ip.py @@ -0,0 +1,193 @@ +"""Tests for pylint hass_enforce_config_entry_unique_id_no_ip plugin.""" + +from __future__ import annotations + +import astroid +from pylint.checkers import BaseChecker +from pylint.testutils.unittest_linter import UnittestLinter +from pylint.utils.ast_walker import ASTWalker +import pytest + +from . import assert_no_messages + + +@pytest.mark.parametrize( + ("code", "module_name"), + [ + pytest.param( + """ + unique_id = format_mac(data["mac"]) + """, + "homeassistant.components.test.config_flow", + id="mac_address", + ), + pytest.param( + """ + unique_id = device_info["serial_number"] + """, + "homeassistant.components.test.config_flow", + id="serial_number", + ), + pytest.param( + """ + await self.async_set_unique_id(device.unique_id) + """, + "homeassistant.components.test.config_flow", + id="device_unique_id", + ), + pytest.param( + """ + unique_id = data[CONF_HOST] + """, + "homeassistant.components.test.config_flow", + id="assign_not_checked", + ), + pytest.param( + """ + await self.async_set_unique_id(device.serial) + """, + "homeassistant.components.test.config_flow", + id="async_set_unique_id_safe_value", + ), + pytest.param( + """ + await self.async_set_unique_id(data[CONF_HOST]) + """, + "homeassistant.components.test.sensor", + id="async_set_unique_id_not_config_flow", + ), + ], +) +def test_enforce_unique_id_no_ip( + linter: UnittestLinter, + enforce_config_entry_unique_id_no_ip_checker: BaseChecker, + code: str, + module_name: str, +) -> None: + """Good test cases.""" + root_node = astroid.parse(code, module_name) + walker = ASTWalker(linter) + walker.add_checker(enforce_config_entry_unique_id_no_ip_checker) + + with assert_no_messages(linter): + walker.walk(root_node) + + +@pytest.mark.parametrize( + ("code", "module_name"), + [ + pytest.param( + """ + await self.async_set_unique_id(entry.data[CONF_HOST]) + """, + "homeassistant.components.test.config_flow", + id="async_set_conf_host", + ), + pytest.param( + """ + await self.async_set_unique_id(data[CONF_IP_ADDRESS]) + """, + "homeassistant.components.test.config_flow", + id="async_set_conf_ip", + ), + pytest.param( + """ + await self.async_set_unique_id(user_input["host"]) + """, + "homeassistant.components.test.config_flow", + id="async_set_string_host", + ), + pytest.param( + """ + await self.async_set_unique_id(unique_id=entry.data[CONF_HOST]) + """, + "homeassistant.components.test.config_flow", + id="async_set_conf_host_keyword", + ), + pytest.param( + """ + await self.async_set_unique_id( + unique_id=data[CONF_IP_ADDRESS], raise_on_progress=False + ) + """, + "homeassistant.components.test.config_flow", + id="async_set_conf_ip_keyword_raise_on_progress_false", + ), + ], +) +def test_enforce_unique_id_no_ip_bad_call( + linter: UnittestLinter, + enforce_config_entry_unique_id_no_ip_checker: BaseChecker, + code: str, + module_name: str, +) -> None: + """Bad async_set_unique_id call test cases.""" + root_node = astroid.parse(code, module_name) + walker = ASTWalker(linter) + walker.add_checker(enforce_config_entry_unique_id_no_ip_checker) + + walker.walk(root_node) + messages = linter.release_messages() + assert len(messages) == 1 + assert messages[0].msg_id == "hass-unique-id-ip-based" + + +@pytest.mark.parametrize( + ("code", "module_name"), + [ + pytest.param( + """ +async def async_step_user(self, user_input=None): + unique_id = data[CONF_HOST] + await self.async_set_unique_id(unique_id) + """, + "homeassistant.components.test.config_flow", + id="variable_from_subscript", + ), + pytest.param( + """ +async def async_step_user(self, user_input=None): + unique_id = f"prefix_{data[CONF_HOST]}" + await self.async_set_unique_id(unique_id) + """, + "homeassistant.components.test.config_flow", + id="variable_from_fstring", + ), + pytest.param( + """ +async def async_step_user(self, user_input=None): + if discovered: + unique_id = device.mac + else: + unique_id = data[CONF_HOST] + await self.async_set_unique_id(unique_id) + """, + "homeassistant.components.test.config_flow", + id="variable_from_conditional", + ), + pytest.param( + """ +async def async_step_user(self, user_input=None): + unique_id = data.get(CONF_HOST) + await self.async_set_unique_id(unique_id) + """, + "homeassistant.components.test.config_flow", + id="variable_from_dict_get", + ), + ], +) +def test_enforce_unique_id_no_ip_bad_call_variable( + linter: UnittestLinter, + enforce_config_entry_unique_id_no_ip_checker: BaseChecker, + code: str, + module_name: str, +) -> None: + """Bad async_set_unique_id call test cases.""" + root_node = astroid.parse(code, module_name) + walker = ASTWalker(linter) + walker.add_checker(enforce_config_entry_unique_id_no_ip_checker) + + walker.walk(root_node) + messages = linter.release_messages() + assert len(messages) == 1 + assert messages[0].msg_id == "hass-unique-id-ip-based"