From 805abfbb46fe7b4440321b2818de7f3b6f0cb198 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:26:45 -0400 Subject: [PATCH 01/18] Add Z-Wave JS node status, configuration parameter, and value conditions (#181133) Co-authored-by: Claude Fable 5.1 --- .../components/zwave_js/condition.py | 273 ++++++++ .../components/zwave_js/conditions.yaml | 213 ++++++ homeassistant/components/zwave_js/const.py | 2 + .../zwave_js/device_automation_helpers.py | 2 - .../components/zwave_js/device_condition.py | 19 +- .../components/zwave_js/device_trigger.py | 2 +- homeassistant/components/zwave_js/helpers.py | 59 +- homeassistant/components/zwave_js/icons.json | 11 + .../components/zwave_js/strings.json | 102 +++ .../components/zwave_js/triggers/event.py | 2 +- .../zwave_js/triggers/trigger_helpers.py | 42 -- .../zwave_js/triggers/value_updated.py | 2 +- tests/components/zwave_js/test_condition.py | 658 ++++++++++++++++++ .../zwave_js/test_device_condition.py | 10 +- tests/components/zwave_js/test_trigger.py | 121 ++-- 15 files changed, 1414 insertions(+), 104 deletions(-) create mode 100644 homeassistant/components/zwave_js/condition.py create mode 100644 homeassistant/components/zwave_js/conditions.yaml delete mode 100644 homeassistant/components/zwave_js/triggers/trigger_helpers.py create mode 100644 tests/components/zwave_js/test_condition.py diff --git a/homeassistant/components/zwave_js/condition.py b/homeassistant/components/zwave_js/condition.py new file mode 100644 index 0000000000000..d1de2dd0b3703 --- /dev/null +++ b/homeassistant/components/zwave_js/condition.py @@ -0,0 +1,273 @@ +"""Offer Z-Wave JS automation conditions.""" + +import abc +from collections.abc import Callable, Iterable +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Unpack, override + +import voluptuous as vol +from zwave_js_server.const import CommandClass +from zwave_js_server.model.node import Node as ZwaveNode + +from homeassistant.const import ATTR_DEVICE_ID, CONF_OPTIONS +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.automation import move_top_level_schema_fields_to_options +from homeassistant.helpers.condition import ( + ATTR_BEHAVIOR, + BEHAVIOR_ALL, + BEHAVIOR_ANY, + Condition, + ConditionCheckParams, + ConditionConfig, +) +from homeassistant.helpers.typing import ConfigType + +from .config_validation import BITMASK_SCHEMA, COMMAND_CLASS_SCHEMA +from .const import ( + ATTR_COMMAND_CLASS, + ATTR_CONFIG_PARAMETER, + ATTR_CONFIG_PARAMETER_BITMASK, + ATTR_ENDPOINT, + ATTR_PROPERTY, + ATTR_PROPERTY_KEY, + ATTR_VALUE, + NODE_STATUSES, +) +from .helpers import ( + async_bypass_dynamic_config_validation, + async_get_node_from_device_id, + get_zwave_value_from_config, + node_status_matches, + value_matches_state, +) + +CONF_STATUS = "status" + +# Conditions compare against state labels, so strings must be kept as given +_CONDITION_VALUE_SCHEMA = vol.Any(bool, int, float, dict, cv.string) + +_BASE_SCHEMA_DICT: dict[vol.Marker, Any] = { + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In( + [BEHAVIOR_ANY, BEHAVIOR_ALL] + ), +} + +_NODE_STATUS_OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = { + **_BASE_SCHEMA_DICT, + vol.Required(CONF_STATUS): vol.In(NODE_STATUSES), +} + +_VALUE_OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = { + **_BASE_SCHEMA_DICT, + vol.Required(ATTR_COMMAND_CLASS): COMMAND_CLASS_SCHEMA, + vol.Required(ATTR_PROPERTY): vol.Any(vol.Coerce(int), cv.string), + vol.Optional(ATTR_ENDPOINT): vol.Coerce(int), + vol.Optional(ATTR_PROPERTY_KEY): vol.Any(vol.Coerce(int), cv.string), + vol.Required(ATTR_VALUE): _CONDITION_VALUE_SCHEMA, +} + +_CONFIG_PARAMETER_OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = { + **_BASE_SCHEMA_DICT, + vol.Required(ATTR_CONFIG_PARAMETER): vol.Coerce(int), + vol.Optional(ATTR_CONFIG_PARAMETER_BITMASK): vol.Any( + vol.Coerce(int), BITMASK_SCHEMA + ), + vol.Optional(ATTR_ENDPOINT, default=0): vol.Coerce(int), + vol.Required(ATTR_VALUE): _CONDITION_VALUE_SCHEMA, +} + + +def _condition_schema(options_schema_dict: dict[vol.Marker, Any]) -> vol.Schema: + """Return the condition schema for an options schema dict.""" + return vol.Schema({vol.Required(CONF_OPTIONS, default={}): options_schema_dict}) + + +@dataclass(slots=True) +class _ResolvedNodes: + """Z-Wave nodes resolved from the targeted devices.""" + + nodes: set[ZwaveNode] = field(default_factory=set) + unresolved: int = 0 + + +@callback +def _async_resolve_nodes( + hass: HomeAssistant, device_ids: Iterable[str] +) -> _ResolvedNodes: + """Resolve targeted device IDs to Z-Wave nodes.""" + dev_reg = dr.async_get(hass) + resolved = _ResolvedNodes() + for device_id in set(device_ids): + try: + node = async_get_node_from_device_id(hass, device_id, dev_reg) + except ValueError: + resolved.unresolved += 1 + else: + resolved.nodes.add(node) + return resolved + + +class _ZwaveNodeCondition(Condition): + """Base for conditions evaluated per Z-Wave node.""" + + options_schema_dict: dict[vol.Marker, Any] + _schema: vol.Schema + + @classmethod + @override + async def async_validate_complete_config( + cls, hass: HomeAssistant, complete_config: ConfigType + ) -> ConfigType: + """Validate complete config.""" + complete_config = move_top_level_schema_fields_to_options( + complete_config, cls.options_schema_dict + ) + return await super().async_validate_complete_config(hass, complete_config) + + @classmethod + @override + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + config = cls._schema(config) + device_ids = config[CONF_OPTIONS][ATTR_DEVICE_ID] + if async_bypass_dynamic_config_validation(hass, {ATTR_DEVICE_ID: device_ids}): + return config + + resolved = _async_resolve_nodes(hass, device_ids) + if not resolved.nodes: + raise vol.Invalid("No nodes found for the given devices") + cls._validate_nodes(resolved.nodes, config[CONF_OPTIONS]) + return config + + @classmethod + def _validate_nodes(cls, nodes: set[ZwaveNode], options: dict[str, Any]) -> None: + """Validate the options against the resolved nodes.""" + + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Initialize condition.""" + super().__init__(hass, config) + if TYPE_CHECKING: + assert config.options is not None + self._options = config.options + + @abc.abstractmethod + def _node_matches(self, node: ZwaveNode) -> bool: + """Return whether a node satisfies the condition.""" + + @override + def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: + """Test the condition against all targeted nodes.""" + resolved = _async_resolve_nodes(self._hass, self._options[ATTR_DEVICE_ID]) + if not resolved.nodes: + return False + behavior_all = self._options[ATTR_BEHAVIOR] == BEHAVIOR_ALL + if behavior_all and resolved.unresolved: + return False + combine: Callable[[Iterable[object]], bool] = all if behavior_all else any + return combine(self._node_matches(node) for node in resolved.nodes) + + +class NodeStatusCondition(_ZwaveNodeCondition): + """Test the status of Z-Wave nodes.""" + + options_schema_dict = _NODE_STATUS_OPTIONS_SCHEMA_DICT + _schema = _condition_schema(_NODE_STATUS_OPTIONS_SCHEMA_DICT) + + @override + def _node_matches(self, node: ZwaveNode) -> bool: + return node_status_matches(node, self._options[CONF_STATUS]) + + +class _ZwaveValueCondition(_ZwaveNodeCondition): + """Base for conditions comparing a Z-Wave value.""" + + @classmethod + @abc.abstractmethod + def _value_config(cls, options: dict[str, Any]) -> dict[str, Any]: + """Return the value lookup config for get_zwave_value_from_config.""" + + @classmethod + @abc.abstractmethod + def _value_description(cls, options: dict[str, Any]) -> str: + """Return a human readable description of the looked up value.""" + + @classmethod + @override + def _validate_nodes(cls, nodes: set[ZwaveNode], options: dict[str, Any]) -> None: + value_config = cls._value_config(options) + for node in nodes: + try: + get_zwave_value_from_config(node, value_config) + except vol.Invalid: + continue + return + raise vol.Invalid(f"No targeted node has {cls._value_description(options)}") + + @override + def _node_matches(self, node: ZwaveNode) -> bool: + try: + value = get_zwave_value_from_config(node, self._value_config(self._options)) + except vol.Invalid: + return False + return value_matches_state(value, self._options[ATTR_VALUE]) + + +class ValueCondition(_ZwaveValueCondition): + """Test a Z-Wave value.""" + + options_schema_dict = _VALUE_OPTIONS_SCHEMA_DICT + _schema = _condition_schema(_VALUE_OPTIONS_SCHEMA_DICT) + + @classmethod + @override + def _value_config(cls, options: dict[str, Any]) -> dict[str, Any]: + return { + ATTR_COMMAND_CLASS: options[ATTR_COMMAND_CLASS], + ATTR_PROPERTY: options[ATTR_PROPERTY], + ATTR_ENDPOINT: options.get(ATTR_ENDPOINT), + ATTR_PROPERTY_KEY: options.get(ATTR_PROPERTY_KEY), + } + + @classmethod + @override + def _value_description(cls, options: dict[str, Any]) -> str: + command_class = CommandClass(options[ATTR_COMMAND_CLASS]) + return f"value {command_class.name}-{options[ATTR_PROPERTY]}" + + +class ConfigParameterCondition(_ZwaveValueCondition): + """Test a Z-Wave configuration parameter.""" + + options_schema_dict = _CONFIG_PARAMETER_OPTIONS_SCHEMA_DICT + _schema = _condition_schema(_CONFIG_PARAMETER_OPTIONS_SCHEMA_DICT) + + @classmethod + @override + def _value_config(cls, options: dict[str, Any]) -> dict[str, Any]: + return { + ATTR_COMMAND_CLASS: CommandClass.CONFIGURATION, + ATTR_PROPERTY: options[ATTR_CONFIG_PARAMETER], + ATTR_PROPERTY_KEY: options.get(ATTR_CONFIG_PARAMETER_BITMASK), + ATTR_ENDPOINT: options[ATTR_ENDPOINT], + } + + @classmethod + @override + def _value_description(cls, options: dict[str, Any]) -> str: + return f"configuration parameter {options[ATTR_CONFIG_PARAMETER]}" + + +CONDITIONS: dict[str, type[Condition]] = { + "node_status": NodeStatusCondition, + "config_parameter": ConfigParameterCondition, + "value": ValueCondition, +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the Z-Wave JS conditions.""" + return CONDITIONS diff --git a/homeassistant/components/zwave_js/conditions.yaml b/homeassistant/components/zwave_js/conditions.yaml new file mode 100644 index 0000000000000..f43b46f7bdda3 --- /dev/null +++ b/homeassistant/components/zwave_js/conditions.yaml @@ -0,0 +1,213 @@ +# Describes the format for available Z-Wave JS conditions + +.device_id: &device_id + required: true + example: 8f4219cfa57e23f6f669c4616c2205e2 + selector: + device: + filter: + - integration: zwave_js + multiple: true + +.behavior: &behavior + required: true + default: any + selector: + automation_behavior: + mode: condition + +.value: &value + required: true + example: 255 + selector: + object: + +node_status: + fields: + device_id: *device_id + behavior: *behavior + status: + required: true + selector: + select: + translation_key: node_status + options: + - alive + - asleep + - awake + - dead + +config_parameter: + fields: + device_id: *device_id + behavior: *behavior + parameter: + required: true + example: 3 + selector: + number: + min: 0 + mode: box + bitmask: + required: false + example: "0x1" + selector: + text: + endpoint: + required: false + default: 0 + selector: + number: + min: 0 + mode: box + value: *value + +value: + fields: + device_id: *device_id + behavior: *behavior + command_class: + required: true + selector: + select: + translation_key: command_class + sort: true + options: + - "0" + - "32" + - "33" + - "34" + - "35" + - "37" + - "38" + - "39" + - "40" + - "41" + - "43" + - "44" + - "45" + - "48" + - "49" + - "50" + - "51" + - "52" + - "53" + - "54" + - "55" + - "57" + - "58" + - "59" + - "60" + - "61" + - "62" + - "63" + - "64" + - "65" + - "66" + - "67" + - "68" + - "69" + - "70" + - "71" + - "72" + - "73" + - "74" + - "75" + - "76" + - "77" + - "78" + - "79" + - "80" + - "81" + - "82" + - "83" + - "84" + - "85" + - "86" + - "87" + - "88" + - "89" + - "90" + - "91" + - "92" + - "93" + - "94" + - "95" + - "96" + - "97" + - "98" + - "99" + - "100" + - "102" + - "103" + - "104" + - "105" + - "106" + - "107" + - "108" + - "109" + - "110" + - "111" + - "112" + - "113" + - "114" + - "115" + - "116" + - "117" + - "118" + - "119" + - "120" + - "121" + - "122" + - "123" + - "124" + - "125" + - "126" + - "128" + - "129" + - "130" + - "132" + - "133" + - "134" + - "135" + - "136" + - "137" + - "138" + - "139" + - "140" + - "142" + - "143" + - "144" + - "145" + - "146" + - "147" + - "148" + - "152" + - "154" + - "155" + - "156" + - "157" + - "158" + - "159" + - "160" + - "161" + - "162" + - "163" + property: + required: true + example: currentValue + selector: + text: + endpoint: + required: false + example: 1 + selector: + number: + min: 0 + mode: box + property_key: + required: false + example: 1 + selector: + text: + value: *value diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index d8b721aa8a95c..329269edde4fd 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -181,6 +181,8 @@ ADDON_SLUG = "core_zwave_js" +NODE_STATUSES = ["asleep", "awake", "dead", "alive"] + # Sensor entity description constants ENTITY_DESC_KEY_BATTERY_LIST_STATE = "battery_list_state" ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY = "battery_maximum_capacity" diff --git a/homeassistant/components/zwave_js/device_automation_helpers.py b/homeassistant/components/zwave_js/device_automation_helpers.py index cb725025f6396..a976e9e5877c7 100644 --- a/homeassistant/components/zwave_js/device_automation_helpers.py +++ b/homeassistant/components/zwave_js/device_automation_helpers.py @@ -8,8 +8,6 @@ from .const import DOMAIN -NODE_STATUSES = ["asleep", "awake", "dead", "alive"] - CONF_SUBTYPE = "subtype" CONF_VALUE_ID = "value_id" diff --git a/homeassistant/components/zwave_js/device_condition.py b/homeassistant/components/zwave_js/device_condition.py index 44a6d714fb2ae..3d04d613fc25d 100644 --- a/homeassistant/components/zwave_js/device_condition.py +++ b/homeassistant/components/zwave_js/device_condition.py @@ -1,10 +1,7 @@ """Provide the device conditions for Z-Wave JS.""" -from typing import cast - import voluptuous as vol from zwave_js_server.const import CommandClass -from zwave_js_server.model.value import ConfigurationValue from homeassistant.components.device_automation import InvalidDeviceAutomationConfig from homeassistant.const import CONF_CONDITION, CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE @@ -21,11 +18,11 @@ ATTR_PROPERTY_KEY, ATTR_VALUE, DOMAIN, + NODE_STATUSES, ) from .device_automation_helpers import ( CONF_SUBTYPE, CONF_VALUE_ID, - NODE_STATUSES, async_bypass_dynamic_config_validation, generate_config_parameter_subtype, ) @@ -34,7 +31,9 @@ check_type_schema_map, get_value_state_schema, get_zwave_value_from_config, + node_status_matches, remove_keys_with_empty_values, + value_matches_state, ) CONF_STATUS = "status" @@ -168,7 +167,7 @@ def async_condition_from_config( def test_node_status(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if node status is a certain state.""" node = async_get_node_from_device_id(hass, device_id) - return bool(node.status.name.lower() == config[CONF_STATUS]) + return node_status_matches(node, config[CONF_STATUS]) if condition_type == NODE_STATUS_TYPE: return test_node_status @@ -177,8 +176,9 @@ def test_node_status(hass: HomeAssistant, variables: TemplateVarsType) -> bool: def test_config_parameter(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if config parameter is a certain state.""" node = async_get_node_from_device_id(hass, device_id) - config_value = cast(ConfigurationValue, node.values[config[CONF_VALUE_ID]]) - return bool(config_value.value == config[ATTR_VALUE]) + return value_matches_state( + node.values[config[CONF_VALUE_ID]], config[ATTR_VALUE] + ) if condition_type == CONFIG_PARAMETER_TYPE: return test_config_parameter @@ -187,8 +187,9 @@ def test_config_parameter(hass: HomeAssistant, variables: TemplateVarsType) -> b def test_value(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if value is a certain state.""" node = async_get_node_from_device_id(hass, device_id) - value = get_zwave_value_from_config(node, config) - return bool(value.value == config[ATTR_VALUE]) + return value_matches_state( + get_zwave_value_from_config(node, config), config[ATTR_VALUE] + ) if condition_type == VALUE_TYPE: return test_value diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index 1225252c383aa..a54f7b9df759d 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -44,12 +44,12 @@ ATTR_VALUE, ATTR_VALUE_RAW, DOMAIN, + NODE_STATUSES, ZWAVE_JS_NOTIFICATION_EVENT, ZWAVE_JS_VALUE_NOTIFICATION_EVENT, ) from .device_automation_helpers import ( CONF_SUBTYPE, - NODE_STATUSES, async_bypass_dynamic_config_validation, generate_config_parameter_subtype, ) diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index b32560b9f219f..93b46d0940e4c 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -34,6 +34,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_AREA_ID, + ATTR_CONFIG_ENTRY_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_TYPE, @@ -468,9 +469,9 @@ def get_zwave_value_from_config(node: ZwaveNode, config: ConfigType) -> ZwaveVal endpoint = None if config.get(ATTR_ENDPOINT): endpoint = config[ATTR_ENDPOINT] - property_key = None - if config.get(ATTR_PROPERTY_KEY): - property_key = config[ATTR_PROPERTY_KEY] + property_key = config.get(ATTR_PROPERTY_KEY) + if property_key == "": + property_key = None value_id = get_value_id_str( node, config[ATTR_COMMAND_CLASS], @@ -483,7 +484,24 @@ def get_zwave_value_from_config(node: ZwaveNode, config: ConfigType) -> ZwaveVal return node.values[value_id] -def _zwave_js_config_entry(hass: HomeAssistant, device: dr.DeviceEntry) -> str | None: +def node_status_matches(node: ZwaveNode, status: str) -> bool: + """Return whether the node has the given status name.""" + return node.status.name.lower() == status + + +def value_matches_state(value: ZwaveValue, expected: Any) -> bool: + """Return whether a value matches the expected raw value, string form or label.""" + current = value.value + return expected in ( + current, + str(current), + value.metadata.states.get(str(current), current), + ) + + +def get_zwave_js_config_entry_id( + hass: HomeAssistant, device: dr.DeviceEntry +) -> str | None: """Find zwave_js config entry from a device.""" _, config_entry = dr.async_get_device_and_config_entry_for_domain( hass, device.id, domain=DOMAIN @@ -506,7 +524,7 @@ def async_get_node_status_sensor_entity_id( if not (device := dev_reg.async_get(device_id, include_child_devices=False)): raise HomeAssistantError("Invalid Device ID provided") - if not (entry_id := _zwave_js_config_entry(hass, device)): + if not (entry_id := get_zwave_js_config_entry_id(hass, device)): return None entry = hass.config_entries.async_get_entry(entry_id) @@ -670,3 +688,34 @@ async def wait_for_events() -> None: class CannotConnect(HomeAssistantError): """Indicate connection error.""" + + +@callback +def async_bypass_dynamic_config_validation( + hass: HomeAssistant, config: ConfigType +) -> bool: + """Return whether a referenced zwave_js config entry is not loaded or ready.""" + dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) + devices = config.get(ATTR_DEVICE_ID, []) + entities = config.get(ATTR_ENTITY_ID, []) + for entry in hass.config_entries.async_entries(DOMAIN): + if not ( + entry.entry_id == config.get(ATTR_CONFIG_ENTRY_ID) + or any( + device.id in devices + for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id) + ) + or any( + entity.entity_id in entities + for entity in er.async_entries_for_config_entry(ent_reg, entry.entry_id) + ) + ): + continue + if entry.state is not ConfigEntryState.LOADED: + return True + # The driver may not be ready when the config entry is loaded. + if entry.runtime_data.client.driver is None: + return True + + return False diff --git a/homeassistant/components/zwave_js/icons.json b/homeassistant/components/zwave_js/icons.json index e03dac535341c..b7afef8ceb761 100644 --- a/homeassistant/components/zwave_js/icons.json +++ b/homeassistant/components/zwave_js/icons.json @@ -1,4 +1,15 @@ { + "conditions": { + "config_parameter": { + "condition": "mdi:cog" + }, + "node_status": { + "condition": "mdi:heart-pulse" + }, + "value": { + "condition": "mdi:update" + } + }, "entity": { "button": { "ping": { diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 19e1dd754a889..4fd7476415090 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -1,4 +1,98 @@ { + "common": { + "condition_behavior_description": "Whether any or every targeted node must match.", + "condition_behavior_name": "Condition passes if", + "condition_device_id_description": "The Z-Wave JS devices whose nodes to test.", + "condition_device_id_name": "Devices", + "condition_endpoint_description": "Endpoint of the value.", + "condition_endpoint_name": "Endpoint", + "condition_value_description": "The value to compare with, either the raw value or its state label.", + "condition_value_name": "Value" + }, + "conditions": { + "config_parameter": { + "description": "Tests if a configuration parameter on one or more Z-Wave JS nodes has the given value.", + "fields": { + "behavior": { + "description": "[%key:component::zwave_js::common::condition_behavior_description%]", + "name": "[%key:component::zwave_js::common::condition_behavior_name%]" + }, + "bitmask": { + "description": "Bitmask of a partial parameter, as a number or hexadecimal string, if the parameter is split into parts.", + "name": "Bitmask" + }, + "device_id": { + "description": "[%key:component::zwave_js::common::condition_device_id_description%]", + "name": "[%key:component::zwave_js::common::condition_device_id_name%]" + }, + "endpoint": { + "description": "[%key:component::zwave_js::common::condition_endpoint_description%]", + "name": "[%key:component::zwave_js::common::condition_endpoint_name%]" + }, + "parameter": { + "description": "Number of the configuration parameter.", + "name": "Parameter" + }, + "value": { + "description": "[%key:component::zwave_js::common::condition_value_description%]", + "name": "[%key:component::zwave_js::common::condition_value_name%]" + } + }, + "name": "Z-Wave JS configuration parameter" + }, + "node_status": { + "description": "Tests if one or more Z-Wave JS nodes have the given status.", + "fields": { + "behavior": { + "description": "[%key:component::zwave_js::common::condition_behavior_description%]", + "name": "[%key:component::zwave_js::common::condition_behavior_name%]" + }, + "device_id": { + "description": "[%key:component::zwave_js::common::condition_device_id_description%]", + "name": "[%key:component::zwave_js::common::condition_device_id_name%]" + }, + "status": { + "description": "The node status to test for.", + "name": "Status" + } + }, + "name": "Z-Wave JS node status" + }, + "value": { + "description": "Tests if a Z-Wave value on one or more nodes equals the given value.", + "fields": { + "behavior": { + "description": "[%key:component::zwave_js::common::condition_behavior_description%]", + "name": "[%key:component::zwave_js::common::condition_behavior_name%]" + }, + "command_class": { + "description": "Command class of the value.", + "name": "Command class" + }, + "device_id": { + "description": "[%key:component::zwave_js::common::condition_device_id_description%]", + "name": "[%key:component::zwave_js::common::condition_device_id_name%]" + }, + "endpoint": { + "description": "[%key:component::zwave_js::common::condition_endpoint_description%]", + "name": "[%key:component::zwave_js::common::condition_endpoint_name%]" + }, + "property": { + "description": "Property of the value.", + "name": "Property" + }, + "property_key": { + "description": "Property key of the value.", + "name": "Property key" + }, + "value": { + "description": "[%key:component::zwave_js::common::condition_value_description%]", + "name": "[%key:component::zwave_js::common::condition_value_name%]" + } + }, + "name": "Z-Wave JS value" + } + }, "config": { "abort": { "addon_already_configured": "A configuration entry using the Z-Wave JS app already exists. Reconfigure or migrate that entry instead.", @@ -562,6 +656,14 @@ "existing": "It already exists", "new": "It's new" } + }, + "node_status": { + "options": { + "alive": "Alive", + "asleep": "Asleep", + "awake": "Awake", + "dead": "Dead" + } } }, "services": { diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 8d67e29410ea1..7dcf1972033f1 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -37,12 +37,12 @@ DOMAIN, ) from ..helpers import ( + async_bypass_dynamic_config_validation, async_get_config_entry_from_node, async_get_nodes_from_targets, get_device_id, get_home_and_node_id_from_device_entry, ) -from .trigger_helpers import async_bypass_dynamic_config_validation # Relative platform type should be RELATIVE_PLATFORM_TYPE = f"{__name__.rsplit('.', maxsplit=1)[-1]}" diff --git a/homeassistant/components/zwave_js/triggers/trigger_helpers.py b/homeassistant/components/zwave_js/triggers/trigger_helpers.py deleted file mode 100644 index 791b262dc7fa3..0000000000000 --- a/homeassistant/components/zwave_js/triggers/trigger_helpers.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Helpers for Z-Wave JS custom triggers.""" - -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.typing import ConfigType - -from ..const import DOMAIN - - -@callback -def async_bypass_dynamic_config_validation( - hass: HomeAssistant, config: ConfigType -) -> bool: - """Return whether target zwave_js config entry is not loaded.""" - # If the config entry is not loaded for a zwave_js device, entity, or the - # config entry ID provided, we can't perform dynamic validation - dev_reg = dr.async_get(hass) - ent_reg = er.async_get(hass) - trigger_devices = config.get(ATTR_DEVICE_ID, []) - trigger_entities = config.get(ATTR_ENTITY_ID, []) - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.state is not ConfigEntryState.LOADED and ( - entry.entry_id == config.get(ATTR_CONFIG_ENTRY_ID) - or any( - device.id in trigger_devices - for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id) - ) - or ( - entity.entity_id in trigger_entities - for entity in er.async_entries_for_config_entry(ent_reg, entry.entry_id) - ) - ): - return True - - # The driver may not be ready when the config entry is loaded. - client = entry.runtime_data.client - if client.driver is None: - return True - - return False diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py index b0784ce998394..32cdaf9544dfe 100644 --- a/homeassistant/components/zwave_js/triggers/value_updated.py +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -40,11 +40,11 @@ EVENT_VALUE_UPDATED, ) from ..helpers import ( + async_bypass_dynamic_config_validation, async_get_config_entry_from_node, async_get_nodes_from_targets, get_device_id, ) -from .trigger_helpers import async_bypass_dynamic_config_validation # Relative platform type should be RELATIVE_PLATFORM_TYPE = f"{__name__.rsplit('.', maxsplit=1)[-1]}" diff --git a/tests/components/zwave_js/test_condition.py b/tests/components/zwave_js/test_condition.py new file mode 100644 index 0000000000000..c714b11aef631 --- /dev/null +++ b/tests/components/zwave_js/test_condition.py @@ -0,0 +1,658 @@ +"""The tests for Z-Wave JS conditions.""" + +from typing import Any +from unittest.mock import MagicMock + +import pytest +import voluptuous as vol +from zwave_js_server.const import CommandClass +from zwave_js_server.event import Event +from zwave_js_server.model.node import Node + +from homeassistant.components.zwave_js import DOMAIN +from homeassistant.components.zwave_js.condition import CONDITIONS +from homeassistant.components.zwave_js.helpers import get_device_id +from homeassistant.core import HomeAssistant +from homeassistant.helpers import ( + condition, + config_validation as cv, + device_registry as dr, +) +from homeassistant.helpers.translation import async_get_translations + +from .common import COMMAND_CLASS_MARKERS + +from tests.common import MockConfigEntry + + +async def _checker( + hass: HomeAssistant, config: dict[str, Any] +) -> condition.ConditionChecker: + """Validate a condition config and build its checker.""" + validated = await condition.async_validate_condition_config( + hass, cv.CONDITION_SCHEMA(config) + ) + return await condition.async_from_config(hass, validated) + + +def _device_id( + device_registry: dr.DeviceRegistry, + client: MagicMock, + node: Node, + entry: MockConfigEntry, +) -> str: + """Return the device registry ID for a node.""" + device = device_registry.async_get_device_by_identifier( + get_device_id(client.driver, node), entry.entry_id + ) + assert device + return device.id + + +@pytest.mark.parametrize( + ("condition_type", "options", "expected"), + [ + pytest.param("node_status", {"status": "alive"}, True, id="node_status_match"), + pytest.param( + "node_status", {"status": "dead"}, False, id="node_status_mismatch" + ), + pytest.param( + "config_parameter", {"parameter": 3, "value": 255}, True, id="param_raw" + ), + pytest.param( + "config_parameter", + {"parameter": 3, "value": "Enable Beeper"}, + True, + id="param_label", + ), + pytest.param( + "config_parameter", {"parameter": 3, "value": 0}, False, id="param_mismatch" + ), + pytest.param( + "value", + {"command_class": "98", "property": "currentMode", "value": "Unsecured"}, + True, + id="value_label", + ), + pytest.param( + "value", + {"command_class": 98, "property": "currentMode", "value": 255}, + False, + id="value_mismatch", + ), + ], +) +async def test_condition_by_device( + hass: HomeAssistant, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, + condition_type: str, + options: dict[str, Any], + expected: bool, +) -> None: + """Test each condition targeted by device.""" + checker = await _checker( + hass, + { + "condition": f"{DOMAIN}.{condition_type}", + "options": { + "device_id": _device_id( + device_registry, client, lock_schlage_be469, integration + ), + **options, + }, + }, + ) + assert checker.async_check() is expected + + +@pytest.mark.parametrize( + ("node_name", "condition_type", "options", "expected"), + [ + pytest.param( + "iblinds_v3", + "config_parameter", + {"parameter": 3, "value": "Enable"}, + True, + id="config_parameter_label_match", + ), + pytest.param( + "iblinds_v3", + "config_parameter", + {"parameter": 3, "value": "Disable"}, + False, + id="config_parameter_label_mismatch", + ), + pytest.param( + "iblinds_v3", + "value", + {"command_class": 112, "property": 3, "value": "0"}, + True, + id="value_raw_string_form", + ), + pytest.param( + "gdc_zw062", + "value", + { + "command_class": 102, + "property": "signalingState", + "property_key": 1, + "value": "On", + }, + True, + id="value_on_label", + ), + ], +) +async def test_condition_value_state_labels( + hass: HomeAssistant, + client: MagicMock, + iblinds_v3: Node, + gdc_zw062: Node, + integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, + node_name: str, + condition_type: str, + options: dict[str, Any], + expected: bool, +) -> None: + """Test state labels and raw string values are compared without coercion.""" + nodes = {"iblinds_v3": iblinds_v3, "gdc_zw062": gdc_zw062} + checker = await _checker( + hass, + { + "condition": f"{DOMAIN}.{condition_type}", + "options": { + "device_id": _device_id( + device_registry, client, nodes[node_name], integration + ), + **options, + }, + }, + ) + assert checker.async_check() is expected + + +@pytest.mark.parametrize( + ("behavior", "target_kind", "expected"), + [ + pytest.param("any", "two_nodes", True, id="any_one_alive"), + pytest.param("all", "two_nodes", False, id="all_one_alive"), + pytest.param("all", "same_node_twice", True, id="all_deduplicated_node"), + ], +) +async def test_node_status_behavior( + hass: HomeAssistant, + client: MagicMock, + lock_schlage_be469: Node, + multisensor_6: Node, + integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, + behavior: str, + target_kind: str, + expected: bool, +) -> None: + """Test any/all behavior, including that a node targeted twice is deduplicated.""" + lock_id = _device_id(device_registry, client, lock_schlage_be469, integration) + device_ids = { + "two_nodes": [ + lock_id, + _device_id(device_registry, client, multisensor_6, integration), + ], + "same_node_twice": [lock_id, lock_id], + } + checker = await _checker( + hass, + { + "condition": f"{DOMAIN}.node_status", + "options": { + "device_id": device_ids[target_kind], + "behavior": behavior, + "status": "alive", + }, + }, + ) + assert checker.async_check() is expected + + +async def test_node_status_follows_events( + hass: HomeAssistant, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the node status condition reflects status changes.""" + checker = await _checker( + hass, + { + "condition": f"{DOMAIN}.node_status", + "options": { + "device_id": _device_id( + device_registry, client, lock_schlage_be469, integration + ), + "status": "dead", + }, + }, + ) + assert checker.async_check() is False + lock_schlage_be469.receive_event( + Event( + "dead", + data={ + "source": "node", + "event": "dead", + "nodeId": lock_schlage_be469.node_id, + }, + ) + ) + assert checker.async_check() is True + + +async def test_node_status_all_two_nodes_match( + hass: HomeAssistant, + client: MagicMock, + lock_schlage_be469: Node, + multisensor_6: Node, + integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test an all behavior only matches once every targeted node matches.""" + checker = await _checker( + hass, + { + "condition": f"{DOMAIN}.node_status", + "options": { + "device_id": [ + _device_id( + device_registry, client, lock_schlage_be469, integration + ), + _device_id(device_registry, client, multisensor_6, integration), + ], + "behavior": "all", + "status": "alive", + }, + }, + ) + assert checker.async_check() is False + multisensor_6.receive_event( + Event( + "alive", + data={ + "source": "node", + "event": "alive", + "nodeId": multisensor_6.node_id, + }, + ) + ) + assert checker.async_check() is True + + +async def test_value_missing_on_node( + hass: HomeAssistant, + client: MagicMock, + lock_schlage_be469: Node, + multisensor_6: Node, + integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a node without the value does not match and validation needs one node with it.""" + lock_id = _device_id(device_registry, client, lock_schlage_be469, integration) + sensor_id = _device_id(device_registry, client, multisensor_6, integration) + options = {"command_class": 98, "property": "currentMode", "value": 0} + + checker = await _checker( + hass, + { + "condition": f"{DOMAIN}.value", + "options": { + "device_id": [lock_id, sensor_id], + **options, + "behavior": "all", + }, + }, + ) + assert checker.async_check() is False + + with pytest.raises(vol.Invalid, match="No targeted node has value"): + await _checker( + hass, + { + "condition": f"{DOMAIN}.value", + "options": {"device_id": sensor_id, **options}, + }, + ) + + +async def test_value_property_key_zero( + hass: HomeAssistant, + client: MagicMock, + bulb_6_multi_color: Node, + integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a property key of 0 is not treated as an absent property key.""" + checker = await _checker( + hass, + { + "condition": f"{DOMAIN}.value", + "options": { + "device_id": _device_id( + device_registry, client, bulb_6_multi_color, integration + ), + "command_class": 51, + "property": "currentColor", + "property_key": 0, + "value": 255, + }, + }, + ) + assert checker.async_check() is True + + +async def test_no_nodes_resolved( + hass: HomeAssistant, + integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test validation rejects devices that resolve to no Z-Wave nodes.""" + other = device_registry.async_get_or_create( + config_entry_id=integration.entry_id, identifiers={("other", "1")} + ) + with pytest.raises(vol.Invalid, match="No nodes found"): + await _checker( + hass, + { + "condition": f"{DOMAIN}.node_status", + "options": {"device_id": other.id, "status": "alive"}, + }, + ) + + +async def test_validation_bypassed_when_not_loaded( + hass: HomeAssistant, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test dynamic validation is skipped while the config entry is not loaded.""" + device_id = _device_id(device_registry, client, lock_schlage_be469, integration) + await hass.config_entries.async_unload(integration.entry_id) + validated = await condition.async_validate_condition_config( + hass, + cv.CONDITION_SCHEMA( + { + "condition": f"{DOMAIN}.value", + "options": { + "device_id": device_id, + "command_class": 98, + "property": "nope", + "value": 0, + }, + } + ), + ) + assert validated["options"]["property"] == "nope" + + +async def test_config_parameter_with_bitmask( + hass: HomeAssistant, + client: MagicMock, + multisensor_6: Node, + integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a config parameter condition with a partial parameter bitmask.""" + checker = await _checker( + hass, + { + "condition": f"{DOMAIN}.config_parameter", + "options": { + "device_id": _device_id( + device_registry, client, multisensor_6, integration + ), + "parameter": 101, + "bitmask": "0x1", + "value": 1, + }, + }, + ) + assert checker.async_check() is True + + +async def test_top_level_fields_moved_to_options( + hass: HomeAssistant, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test top level option fields are moved into the options block.""" + device_id = _device_id(device_registry, client, lock_schlage_be469, integration) + validated = await condition.async_validate_condition_config( + hass, + cv.CONDITION_SCHEMA( + { + "condition": f"{DOMAIN}.node_status", + "device_id": device_id, + "status": "alive", + "behavior": "all", + } + ), + ) + assert validated["options"] == { + "device_id": [device_id], + "behavior": "all", + "status": "alive", + } + assert "status" not in validated + + +async def test_check_false_when_nodes_disappear( + hass: HomeAssistant, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the condition is False once the devices no longer resolve to nodes.""" + checker = await _checker( + hass, + { + "condition": f"{DOMAIN}.node_status", + "options": { + "device_id": _device_id( + device_registry, client, lock_schlage_be469, integration + ), + "status": "alive", + }, + }, + ) + assert checker.async_check() is True + await hass.config_entries.async_unload(integration.entry_id) + assert checker.async_check() is False + + +@pytest.mark.parametrize( + ("behavior", "expected"), + [("any", True), ("all", False)], +) +async def test_partially_unresolved_target( + hass: HomeAssistant, + client: MagicMock, + lock_schlage_be469: Node, + multisensor_6: Node, + integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, + behavior: str, + expected: bool, +) -> None: + """Test a targeted Z-Wave node that cannot be resolved fails an all behavior.""" + device_ids = [ + _device_id(device_registry, client, lock_schlage_be469, integration), + _device_id(device_registry, client, multisensor_6, integration), + ] + del client.driver.controller.nodes[multisensor_6.node_id] + checker = await _checker( + hass, + { + "condition": f"{DOMAIN}.node_status", + "options": { + "device_id": device_ids, + "behavior": behavior, + "status": "alive", + }, + }, + ) + assert checker.async_check() is expected + + +async def test_config_parameter_missing_on_node( + hass: HomeAssistant, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test validation fails when no node in the target has the parameter.""" + device_id = _device_id(device_registry, client, lock_schlage_be469, integration) + with pytest.raises(vol.Invalid, match="configuration parameter"): + await _checker( + hass, + { + "condition": f"{DOMAIN}.config_parameter", + "options": { + "device_id": device_id, + "parameter": 9999, + "value": 1, + }, + }, + ) + + +@pytest.mark.parametrize("condition_type", list(CONDITIONS), ids=list(CONDITIONS)) +@pytest.mark.usefixtures("integration") +async def test_condition_description_fields_match_schema( + hass: HomeAssistant, condition_type: str +) -> None: + """Test the described fields and required flags match the options schema.""" + schema = CONDITIONS[condition_type].options_schema_dict + descriptions = await condition.async_get_all_descriptions(hass) + description = descriptions[f"{DOMAIN}.{condition_type}"] + # Nodes are targeted with a device selector field, not a target selector + assert "target" not in description + fields = description["fields"] + assert set(fields) == {str(key) for key in schema} + assert {name for name, field in fields.items() if field["required"]} == { + str(key) for key in schema if isinstance(key, vol.Required) + } + + +@pytest.mark.parametrize("condition_type", list(CONDITIONS), ids=list(CONDITIONS)) +@pytest.mark.usefixtures("integration") +async def test_condition_device_selector( + hass: HomeAssistant, condition_type: str +) -> None: + """Test every condition picks nodes with a multiple zwave_js device selector.""" + descriptions = await condition.async_get_all_descriptions(hass) + selector = descriptions[f"{DOMAIN}.{condition_type}"]["fields"]["device_id"][ + "selector" + ]["device"] + assert selector["filter"] == [{"integration": DOMAIN}] + assert selector["multiple"] is True + + +@pytest.mark.usefixtures("integration") +async def test_value_command_class_options(hass: HomeAssistant) -> None: + """Test the value condition's command class options match the CommandClass enum.""" + expected = {str(cc.value) for cc in CommandClass if cc not in COMMAND_CLASS_MARKERS} + descriptions = await condition.async_get_all_descriptions(hass) + options = descriptions[f"{DOMAIN}.value"]["fields"]["command_class"]["selector"][ + "select" + ]["options"] + assert len(options) == len(expected) + assert set(options) == expected + + +@pytest.mark.usefixtures("integration") +async def test_node_status_selector_translations(hass: HomeAssistant) -> None: + """Test the node status selector options are translated.""" + translations = await async_get_translations(hass, "en", "selector", {DOMAIN}) + prefix = f"component.{DOMAIN}.selector.node_status.options." + assert { + key.removeprefix(prefix) for key in translations if key.startswith(prefix) + } == {"alive", "asleep", "awake", "dead"} + + +async def test_non_zwave_device_is_unresolved( + hass: HomeAssistant, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a device from another integration counts as an unresolved node.""" + other_entry = MockConfigEntry(domain="other") + other_entry.add_to_hass(hass) + other_device = device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, identifiers={("other", "dev")} + ) + device_ids = [ + _device_id(device_registry, client, lock_schlage_be469, integration), + other_device.id, + ] + assert ( + await _checker( + hass, + { + "condition": f"{DOMAIN}.node_status", + "options": { + "device_id": device_ids, + "behavior": "any", + "status": "alive", + }, + }, + ) + ).async_check() is True + assert ( + await _checker( + hass, + { + "condition": f"{DOMAIN}.node_status", + "options": { + "device_id": device_ids, + "behavior": "all", + "status": "alive", + }, + }, + ) + ).async_check() is False + + +async def test_value_empty_property_key( + hass: HomeAssistant, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test an empty property key is treated as no property key.""" + checker = await _checker( + hass, + { + "condition": f"{DOMAIN}.value", + "options": { + "device_id": _device_id( + device_registry, client, lock_schlage_be469, integration + ), + "command_class": 112, + "property": 3, + "property_key": "", + "value": 255, + }, + }, + ) + assert checker.async_check() is True diff --git a/tests/components/zwave_js/test_device_condition.py b/tests/components/zwave_js/test_device_condition.py index 0270e74ef1b39..ea3023a337a34 100644 --- a/tests/components/zwave_js/test_device_condition.py +++ b/tests/components/zwave_js/test_device_condition.py @@ -372,6 +372,13 @@ async def test_config_parameter_state( assert service_calls[1].data["some"] == "User Slot Status - event - test_event2" +@pytest.mark.parametrize( + "value", + [ + pytest.param(255, id="raw_value"), + pytest.param("Enable Beeper", id="state_label"), + ], +) async def test_value_state( hass: HomeAssistant, client, @@ -379,6 +386,7 @@ async def test_value_state( integration, service_calls: list[ServiceCall], device_registry: dr.DeviceRegistry, + value: int | str, ) -> None: """Test for value conditions.""" device = device_registry.async_get_device_by_identifier( @@ -401,7 +409,7 @@ async def test_value_state( "type": "value", "command_class": 112, "property": 3, - "value": 255, + "value": value, } ], "action": { diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 9f9aef4e00cea..d1637899d39c1 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -12,17 +12,18 @@ from homeassistant.components import automation from homeassistant.components.zwave_js import DOMAIN -from homeassistant.components.zwave_js.helpers import get_device_id +from homeassistant.components.zwave_js.helpers import ( + async_bypass_dynamic_config_validation, + get_device_id, +) from homeassistant.components.zwave_js.trigger import TRIGGERS from homeassistant.components.zwave_js.triggers.event import ( _OPTIONS_SCHEMA_DICT as EVENT_OPTIONS_SCHEMA_DICT, ) -from homeassistant.components.zwave_js.triggers.trigger_helpers import ( - async_bypass_dynamic_config_validation, -) from homeassistant.components.zwave_js.triggers.value_updated import ( _OPTIONS_SCHEMA_DICT as VALUE_UPDATED_OPTIONS_SCHEMA_DICT, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, trigger @@ -1145,12 +1146,9 @@ async def test_zwave_js_trigger_config_entry_unloaded( assert not async_bypass_dynamic_config_validation( hass, { - "platform": f"{DOMAIN}.value_updated", - "options": { - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", - }, + "entity_id": [SCHLAGE_BE469_LOCK_ENTITY], + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", }, ) @@ -1185,66 +1183,105 @@ async def test_zwave_js_trigger_config_entry_unloaded( assert async_bypass_dynamic_config_validation( hass, { - "platform": f"{DOMAIN}.value_updated", - "options": { - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", - }, + "entity_id": [SCHLAGE_BE469_LOCK_ENTITY], + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", }, ) assert async_bypass_dynamic_config_validation( hass, { - "platform": f"{DOMAIN}.value_updated", - "options": { - "device_id": device.id, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", - "from": "ajar", - }, + "device_id": [device.id], + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "from": "ajar", }, ) assert async_bypass_dynamic_config_validation( hass, { - "platform": f"{DOMAIN}.event", - "options": { - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "event_source": "node", - "event": "interview stage completed", - }, + "entity_id": [SCHLAGE_BE469_LOCK_ENTITY], + "event_source": "node", + "event": "interview stage completed", }, ) assert async_bypass_dynamic_config_validation( hass, { - "platform": f"{DOMAIN}.event", - "options": { - "device_id": device.id, - "event_source": "node", - "event": "interview stage completed", - "event_data": {"stageName": "ProtocolInfo"}, - }, + "device_id": [device.id], + "event_source": "node", + "event": "interview stage completed", + "event_data": {"stageName": "ProtocolInfo"}, }, ) assert async_bypass_dynamic_config_validation( hass, { - "platform": f"{DOMAIN}.event", - "options": { - "config_entry_id": integration.entry_id, - "event_source": "controller", - "event": "nvm convert progress", - }, + "config_entry_id": integration.entry_id, + "event_source": "controller", + "event": "nvm convert progress", }, ) +@pytest.mark.parametrize( + ("config_key", "driver", "expected"), + [ + pytest.param("loaded_device", MagicMock(), False, id="loaded_device"), + pytest.param("loaded_entity", MagicMock(), False, id="loaded_entity"), + pytest.param("unloaded_device", MagicMock(), True, id="unloaded_device"), + pytest.param( + "unloaded_entry", MagicMock(), True, id="unloaded_config_entry_id" + ), + pytest.param("nothing", MagicMock(), False, id="nothing_referenced"), + pytest.param("loaded_device", None, True, id="loaded_device_driver_not_ready"), + pytest.param("nothing", None, False, id="nothing_referenced_driver_not_ready"), + ], +) +async def test_bypass_dynamic_config_validation_scoped( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, + config_key: str, + driver: MagicMock | None, + expected: bool, +) -> None: + """Test the bypass check only considers config entries referenced by the config.""" + lock_device = device_registry.async_get_device_by_identifier( + get_device_id(client.driver, lock_schlage_be469), integration.entry_id + ) + assert lock_device + + other_entry = MockConfigEntry( + domain=DOMAIN, data={"url": "ws://test2.org"}, unique_id="other" + ) + other_entry.add_to_hass(hass) + other_device = device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, identifiers={(DOMAIN, "other-node")} + ) + assert other_entry.state is not ConfigEntryState.LOADED + + configs = { + "loaded_device": {"device_id": [lock_device.id]}, + "loaded_entity": {"entity_id": [SCHLAGE_BE469_LOCK_ENTITY]}, + "unloaded_device": {"device_id": [other_device.id]}, + "unloaded_entry": {"config_entry_id": other_entry.entry_id}, + "nothing": {}, + } + + with patch.object(client, "driver", driver): + assert ( + async_bypass_dynamic_config_validation(hass, configs[config_key]) + is expected + ) + + async def test_server_reconnect_event( hass: HomeAssistant, client, From 813beb8c854640db27ab915cc3fa988b5c088379 Mon Sep 17 00:00:00 2001 From: Sven Delmas Date: Thu, 10 Sep 2026 03:37:48 -0600 Subject: [PATCH 02/18] Report UniFi WAN latency as unknown when a monitor is unresponsive (#181809) Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Ariel Ebersberger --- homeassistant/components/unifi/sensor.py | 2 +- .../unifi/snapshots/test_sensor.ambr | 6 +-- tests/components/unifi/test_sensor.py | 37 ++++++++++++------- 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index d06d50b286e8b..61698aab9c07a 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -263,7 +263,7 @@ def async_device_wan_latency_value_fn( # Checked by async_device_wan_latency_supported_fn assert target - return target.get("latency_average", 0) + return target.get("latency_average") @callback diff --git a/tests/components/unifi/snapshots/test_sensor.ambr b/tests/components/unifi/snapshots/test_sensor.ambr index b2e1383d50930..441bdf86b1c4c 100644 --- a/tests/components/unifi/snapshots/test_sensor.ambr +++ b/tests/components/unifi/snapshots/test_sensor.ambr @@ -815,7 +815,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': 'unknown', }) # --- # name: test_entity_and_device_data[wlan_payload0-device_payload0-client_payload0-config_entry_options0][sensor.mock_name_cloudflare_wan_latency-entry] @@ -931,7 +931,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': 'unknown', }) # --- # name: test_entity_and_device_data[wlan_payload0-device_payload0-client_payload0-config_entry_options0][sensor.mock_name_google_wan_latency-entry] @@ -1047,7 +1047,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': 'unknown', }) # --- # name: test_entity_and_device_data[wlan_payload0-device_payload0-client_payload0-config_entry_options0][sensor.mock_name_microsoft_wan_latency-entry] diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index bf346b4b12d35..80e65b8651f9c 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -1710,14 +1710,22 @@ async def test_device_uptime( ], ) @pytest.mark.parametrize( - ("monitor_id", "state", "updated_state", "index_to_update"), + ("monitor_id", "state", "index_to_update", "monitor_update", "updated_state"), [ - # Microsoft - ("microsoft_wan", "56", "20", 0), - # Google - ("google_wan", "53", "90", 1), - # Cloudflare - ("cloudflare_wan", "30", "80", 2), + pytest.param( + "microsoft_wan", "56", 0, {"latency_average": 20}, "20", id="microsoft" + ), + pytest.param("google_wan", "53", 1, {"latency_average": 90}, "90", id="google"), + pytest.param( + "cloudflare_wan", "30", 2, {"latency_average": 80}, "80", id="cloudflare" + ), + pytest.param( + "microsoft_wan", "56", 0, {}, STATE_UNKNOWN, id="microsoft_no_response" + ), + pytest.param("google_wan", "53", 1, {}, STATE_UNKNOWN, id="google_no_response"), + pytest.param( + "cloudflare_wan", "30", 2, {}, STATE_UNKNOWN, id="cloudflare_no_response" + ), ], ) @pytest.mark.usefixtures("config_entry_setup") @@ -1728,8 +1736,9 @@ async def test_wan_monitor_latency( device_payload: list[dict[str, Any]], monitor_id: str, state: str, - updated_state: str, index_to_update: int, + monitor_update: dict[str, Any], + updated_state: str, ) -> None: """Verify that wan latency sensors are working as expected.""" entity_id = f"sensor.mock_name_{monitor_id}_latency" @@ -1757,14 +1766,14 @@ async def test_wan_monitor_latency( # Verify sensor state assert hass.states.get(entity_id).state == state - # Verify state update - device = device_payload[0] - device["uptime_stats"]["WAN"]["monitors"][index_to_update]["latency_average"] = ( - updated_state - ) - + # Update state + device = deepcopy(device_payload[0]) + monitor = device["uptime_stats"]["WAN"]["monitors"][index_to_update] + monitor.pop("latency_average") + monitor.update(monitor_update) mock_websocket_message(message=MessageKey.DEVICE, data=device) + # Verify state update assert hass.states.get(entity_id).state == updated_state From 8ac1e8392af5346a54ceb638aed6a42b7c783071 Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Thu, 10 Sep 2026 11:41:51 +0200 Subject: [PATCH 03/18] Retry sftp_storage setup when the SSH connection fails (#181769) --- .../components/sftp_storage/__init__.py | 6 +- .../components/sftp_storage/client.py | 17 +++++- tests/components/sftp_storage/test_init.py | 55 +++++++++++++++++-- 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sftp_storage/__init__.py b/homeassistant/components/sftp_storage/__init__.py index fdf2ff596b738..8aa63be3c2bf2 100644 --- a/homeassistant/components/sftp_storage/__init__.py +++ b/homeassistant/components/sftp_storage/__init__.py @@ -10,9 +10,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady -from .client import BackupAgentClient +from .client import BackupAgentClient, SFTPConnectionError from .const import ( CONF_BACKUP_LOCATION, CONF_PRIVATE_KEY_FILE, @@ -55,6 +55,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SFTPConfigEntry) -> bool try: client = BackupAgentClient(entry, hass) await client.open() + except SFTPConnectionError as e: + raise ConfigEntryNotReady(str(e)) from e except BackupAgentError as e: raise ConfigEntryError from e diff --git a/homeassistant/components/sftp_storage/client.py b/homeassistant/components/sftp_storage/client.py index a50df34a251b9..15b15700691b4 100644 --- a/homeassistant/components/sftp_storage/client.py +++ b/homeassistant/components/sftp_storage/client.py @@ -13,7 +13,7 @@ SSHClientConnectionOptions, connect, ) -from asyncssh.misc import PermissionDenied +from asyncssh.misc import Error as SSHError, PermissionDenied from asyncssh.sftp import SFTPNoSuchFile, SFTPPermissionDenied from homeassistant.components.backup import ( @@ -29,6 +29,10 @@ from . import SFTPConfigEntry, SFTPConfigEntryData +class SFTPConnectionError(BackupAgentError): + """Error raised when the SSH connection could not be established.""" + + def get_client_options(cfg: SFTPConfigEntryData) -> SSHClientConnectionOptions: """Get `SSHClientConnectionOptions` for use with `hass.async_add_executor_job`.""" @@ -295,12 +299,17 @@ async def open(self) -> BackupAgentClient: get_client_options, self.cfg.runtime_data ), ) - except (OSError, PermissionDenied) as e: + except PermissionDenied as e: raise BackupAgentError( "Failure while attempting to establish SSH" " connection. Please check SSH credentials" " and if changed, re-install the integration" ) from e + except (OSError, SSHError) as e: + raise SFTPConnectionError( + f"Failed to establish SSH connection to" + f" {self.cfg.runtime_data.host}: {e}" + ) from e # Configure SFTP Client Connection try: @@ -311,5 +320,9 @@ async def open(self) -> BackupAgentClient: "Failed to create SFTP client." " Re-installing integration might be required" ) from e + except (OSError, SSHError) as e: + raise SFTPConnectionError( + f"Failed to open SFTP session on {self.cfg.runtime_data.host}: {e}" + ) from e return self diff --git a/tests/components/sftp_storage/test_init.py b/tests/components/sftp_storage/test_init.py index d52a234821356..e14049922adf7 100644 --- a/tests/components/sftp_storage/test_init.py +++ b/tests/components/sftp_storage/test_init.py @@ -3,7 +3,8 @@ from pathlib import Path from unittest.mock import patch -from asyncssh.sftp import SFTPPermissionDenied +from asyncssh.misc import ChannelOpenError, ConnectionLost, PermissionDenied +from asyncssh.sftp import SFTPConnectionLost, SFTPPermissionDenied import pytest from homeassistant.components.sftp_storage import SFTPConfigEntryData @@ -73,15 +74,61 @@ async def test_setup_error( assert entries[0].state is ConfigEntryState.SETUP_ERROR -async def test_setup_unexpected_error( +@pytest.mark.parametrize( + "connect_error", + [OSError("Error message"), ConnectionLost("Connection lost")], + ids=["oserror", "connection_lost"], +) +async def test_setup_connection_error_is_retried( hass: HomeAssistant, setup_integration: ComponentSetup, caplog: pytest.LogCaptureFixture, + connect_error: Exception, ) -> None: - """Test setup error.""" + """Test that a connection failure leaves the entry in a retrying state.""" + with patch( + "homeassistant.components.sftp_storage.client.connect", + side_effect=connect_error, + ): + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_RETRY + assert "Failed to establish SSH connection to" in caplog.text + + +@pytest.mark.parametrize( + "sftp_error", + [ChannelOpenError(1, "Channel open failed"), SFTPConnectionLost("Connection lost")], + ids=["channel_open_error", "sftp_connection_lost"], +) +async def test_setup_sftp_session_error_is_retried( + mock_ssh_connection: SSHClientConnectionMock, + hass: HomeAssistant, + setup_integration: ComponentSetup, + caplog: pytest.LogCaptureFixture, + sftp_error: Exception, +) -> None: + """Test that losing the session after connecting is also retried.""" + mock_ssh_connection._sftp._mock_chdir.side_effect = sftp_error + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_RETRY + assert "Failed to open SFTP session on" in caplog.text + + +async def test_setup_invalid_credentials( + hass: HomeAssistant, + setup_integration: ComponentSetup, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that rejected credentials are not retried.""" with patch( "homeassistant.components.sftp_storage.client.connect", - side_effect=OSError("Error message"), + side_effect=PermissionDenied("Permission denied"), ): await setup_integration() From 5145ccd15f69ed7c7339679b294a84e9021e7cb6 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 10 Sep 2026 11:58:15 +0200 Subject: [PATCH 04/18] Align IQS rules status for Midea (#181799) --- homeassistant/components/midea/quality_scale.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/midea/quality_scale.yaml b/homeassistant/components/midea/quality_scale.yaml index faf047a4e52a1..5a6763ccc94c9 100644 --- a/homeassistant/components/midea/quality_scale.yaml +++ b/homeassistant/components/midea/quality_scale.yaml @@ -47,12 +47,12 @@ rules: discovery-update-info: todo discovery: todo docs-data-update: done - docs-examples: todo + docs-examples: done docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo - docs-use-cases: todo + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: todo entity-category: todo entity-device-class: todo From 4679f18149e7862633c703ad632b507303cf76e3 Mon Sep 17 00:00:00 2001 From: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:00:48 +0200 Subject: [PATCH 05/18] Fix atag climate snapshot (#181832) --- tests/components/atag/snapshots/test_climate.ambr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/atag/snapshots/test_climate.ambr b/tests/components/atag/snapshots/test_climate.ambr index 5e96e0dc77e21..6484e8334f2a3 100644 --- a/tests/components/atag/snapshots/test_climate.ambr +++ b/tests/components/atag/snapshots/test_climate.ambr @@ -78,6 +78,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'heat', + 'state': 'auto', }) # --- From 986e98371f6e903392e01028270a5dff3bd513ba Mon Sep 17 00:00:00 2001 From: Federico Zivolo <5382443+FezVrasta@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:18:16 +0200 Subject: [PATCH 06/18] Bump iseo-argo-ble to 0.9.10 (#181828) --- homeassistant/components/iseo_argo_ble/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/iseo_argo_ble/manifest.json b/homeassistant/components/iseo_argo_ble/manifest.json index 4c4ccd6ff250a..f45bf3f607b7e 100644 --- a/homeassistant/components/iseo_argo_ble/manifest.json +++ b/homeassistant/components/iseo_argo_ble/manifest.json @@ -75,5 +75,5 @@ "iot_class": "local_polling", "loggers": ["iseo_argo_ble"], "quality_scale": "bronze", - "requirements": ["iseo-argo-ble==0.9.8"] + "requirements": ["iseo-argo-ble==0.9.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index b575c3b60a037..57b5c23dc5481 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1426,7 +1426,7 @@ irm-kmi-api==1.1.1 isal==1.8.0 # homeassistant.components.iseo_argo_ble -iseo-argo-ble==0.9.8 +iseo-argo-ble==0.9.10 # homeassistant.components.gogogate2 ismartgate==5.0.2 From fa89b22a64a955bcff908fe9b98bcf0a331e92ae Mon Sep 17 00:00:00 2001 From: Trond Elde Date: Thu, 10 Sep 2026 13:26:59 +0200 Subject: [PATCH 07/18] Fix Flexit unit ID range (#181788) --- homeassistant/components/flexit/config_flow.py | 2 +- tests/components/flexit/test_config_flow.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/flexit/config_flow.py b/homeassistant/components/flexit/config_flow.py index 807984c753e84..493cd02525169 100644 --- a/homeassistant/components/flexit/config_flow.py +++ b/homeassistant/components/flexit/config_flow.py @@ -34,7 +34,7 @@ _LOGGER = logging.getLogger(__name__) UNIT_SELECTOR = vol.All( - NumberSelector(NumberSelectorConfig(min=1, max=247, mode=NumberSelectorMode.BOX)), + NumberSelector(NumberSelectorConfig(min=1, max=31, mode=NumberSelectorMode.BOX)), vol.Coerce(int), ) diff --git a/tests/components/flexit/test_config_flow.py b/tests/components/flexit/test_config_flow.py index 73b85fbef778d..6b4b58ba9030e 100644 --- a/tests/components/flexit/test_config_flow.py +++ b/tests/components/flexit/test_config_flow.py @@ -84,7 +84,7 @@ async def test_tcp_host_is_normalized(hass: HomeAssistant) -> None: async def test_maximum_unit(hass: HomeAssistant) -> None: - """Test the maximum Modbus unit ID is accepted.""" + """Test the maximum Flexit unit ID is accepted.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -93,16 +93,16 @@ async def test_maximum_unit(hass: HomeAssistant) -> None: ) result = await hass.config_entries.flow.async_configure( - result["flow_id"], {**TCP_USER_INPUT, CONF_UNIT: 247} + result["flow_id"], {**TCP_USER_INPUT, CONF_UNIT: 31} ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_UNIT] == 247 + assert result["data"][CONF_UNIT] == 31 -@pytest.mark.parametrize("unit", [0, 248]) +@pytest.mark.parametrize("unit", [0, 32]) async def test_unit_out_of_range(hass: HomeAssistant, unit: int) -> None: - """Test unit IDs outside the Modbus address range are rejected.""" + """Test unit IDs outside the Flexit address range are rejected.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) From d1d6e9bd973a5f1e248039e08307f4e684099bc5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 10 Sep 2026 13:51:51 +0200 Subject: [PATCH 08/18] Fix zwave_js condition._async_resolve_nodes (#181841) --- homeassistant/components/zwave_js/condition.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/condition.py b/homeassistant/components/zwave_js/condition.py index d1de2dd0b3703..aeafd5a052af0 100644 --- a/homeassistant/components/zwave_js/condition.py +++ b/homeassistant/components/zwave_js/condition.py @@ -11,7 +11,7 @@ from homeassistant.const import ATTR_DEVICE_ID, CONF_OPTIONS from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import move_top_level_schema_fields_to_options from homeassistant.helpers.condition import ( ATTR_BEHAVIOR, @@ -97,11 +97,10 @@ def _async_resolve_nodes( hass: HomeAssistant, device_ids: Iterable[str] ) -> _ResolvedNodes: """Resolve targeted device IDs to Z-Wave nodes.""" - dev_reg = dr.async_get(hass) resolved = _ResolvedNodes() for device_id in set(device_ids): try: - node = async_get_node_from_device_id(hass, device_id, dev_reg) + node = async_get_node_from_device_id(hass, device_id) except ValueError: resolved.unresolved += 1 else: From 689a5d61033a4e5ec2c5aa3b6453488bf4bafe4b Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Thu, 10 Sep 2026 15:08:07 +0300 Subject: [PATCH 09/18] Bump openai to 3.10.0 (#181779) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/components/litellm/config_flow.py | 5 +++-- homeassistant/components/litellm/coordinator.py | 5 +++-- homeassistant/components/litellm/manifest.json | 2 +- homeassistant/components/llama_cpp/api.py | 3 ++- homeassistant/components/llama_cpp/manifest.json | 2 +- homeassistant/components/open_router/__init__.py | 5 ++++- homeassistant/components/open_router/manifest.json | 2 +- homeassistant/components/openai_conversation/__init__.py | 6 ++++-- homeassistant/components/openai_conversation/ai_task.py | 2 +- .../components/openai_conversation/config_flow.py | 8 +++++--- .../components/openai_conversation/manifest.json | 2 +- .../components/ovhcloud_ai_endpoints/__init__.py | 5 ++++- .../components/ovhcloud_ai_endpoints/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- tests/components/openai_conversation/conftest.py | 6 ++++-- tests/components/openai_conversation/test_init.py | 6 ++++-- 18 files changed, 42 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index a21f09bf38ea3..11e61ca17e7e5 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -19,6 +19,6 @@ "learn_more_url": "https://support.nabucasa.com/hc/en-us/articles/29718084245149-Speech-to-text-STT" } }, - "requirements": ["hass-nabucasa==2.7.0", "openai==2.45.0"], + "requirements": ["hass-nabucasa==2.7.0", "openai==3.10.0"], "single_config_entry": true } diff --git a/homeassistant/components/litellm/config_flow.py b/homeassistant/components/litellm/config_flow.py index e22fbcce329da..177bf7e24ed74 100644 --- a/homeassistant/components/litellm/config_flow.py +++ b/homeassistant/components/litellm/config_flow.py @@ -1,7 +1,7 @@ """Config flow for LiteLLM integration.""" import logging -from typing import Any, override +from typing import Any, cast, override from openai import AsyncOpenAI, AuthenticationError, OpenAIError, PermissionDeniedError import voluptuous as vol @@ -65,7 +65,8 @@ async def _get_models(hass: HomeAssistant, url: str, api_key: str | None) -> lis client = AsyncOpenAI( base_url=url, api_key=api_key or PLACEHOLDER_API_KEY, - http_client=get_async_client(hass), + # Legacy HTTPX clients are supported at runtime only. + http_client=cast(Any, get_async_client(hass)), ) try: return [ diff --git a/homeassistant/components/litellm/coordinator.py b/homeassistant/components/litellm/coordinator.py index ecd856bf6fd88..47cba3f72539b 100644 --- a/homeassistant/components/litellm/coordinator.py +++ b/homeassistant/components/litellm/coordinator.py @@ -1,7 +1,7 @@ """Coordinator for the LiteLLM integration.""" from datetime import timedelta -from typing import override +from typing import Any, cast, override from openai import AsyncOpenAI, AuthenticationError, OpenAIError, PermissionDeniedError @@ -40,7 +40,8 @@ def __init__(self, hass: HomeAssistant, config_entry: LiteLLMConfigEntry) -> Non self.client = AsyncOpenAI( base_url=config_entry.data[CONF_URL], api_key=config_entry.data.get(CONF_API_KEY) or PLACEHOLDER_API_KEY, - http_client=get_async_client(hass), + # Legacy HTTPX clients are supported at runtime only. + http_client=cast(Any, get_async_client(hass)), ) @override diff --git a/homeassistant/components/litellm/manifest.json b/homeassistant/components/litellm/manifest.json index 595ec0710b38b..5342871ba9830 100644 --- a/homeassistant/components/litellm/manifest.json +++ b/homeassistant/components/litellm/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["openai==2.45.0"] + "requirements": ["openai==3.10.0"] } diff --git a/homeassistant/components/llama_cpp/api.py b/homeassistant/components/llama_cpp/api.py index bf2c7bfa39598..5ee7f95014b69 100644 --- a/homeassistant/components/llama_cpp/api.py +++ b/homeassistant/components/llama_cpp/api.py @@ -62,7 +62,8 @@ async def async_create_client( client = openai.AsyncOpenAI( api_key=api_key, base_url=config_entry_data[CONF_BASE_URL], - http_client=get_async_client(hass), + # Legacy HTTPX clients are supported at runtime only. + http_client=cast(Any, get_async_client(hass)), ) # Cache current platform data which gets added to each request # (caching done by library) diff --git a/homeassistant/components/llama_cpp/manifest.json b/homeassistant/components/llama_cpp/manifest.json index a610d90272082..b3599d7eeaf00 100644 --- a/homeassistant/components/llama_cpp/manifest.json +++ b/homeassistant/components/llama_cpp/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["openai==2.45.0"] + "requirements": ["openai==3.10.0"] } diff --git a/homeassistant/components/open_router/__init__.py b/homeassistant/components/open_router/__init__.py index e25ab075383d8..e09406740745c 100644 --- a/homeassistant/components/open_router/__init__.py +++ b/homeassistant/components/open_router/__init__.py @@ -1,5 +1,7 @@ """The OpenRouter integration.""" +from typing import Any, cast + from openai import AsyncOpenAI, AuthenticationError, OpenAIError from homeassistant.config_entries import ConfigEntry @@ -20,7 +22,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenRouterConfigEntry) - client = AsyncOpenAI( base_url="https://openrouter.ai/api/v1", api_key=entry.data[CONF_API_KEY], - http_client=get_async_client(hass), + # Legacy HTTPX clients are supported at runtime only. + http_client=cast(Any, get_async_client(hass)), ) # Cache current platform data which gets added to each request diff --git a/homeassistant/components/open_router/manifest.json b/homeassistant/components/open_router/manifest.json index 8b835969cbe0f..fa80c1fa7a19c 100644 --- a/homeassistant/components/open_router/manifest.json +++ b/homeassistant/components/open_router/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["openai==2.45.0", "python-open-router==0.4.0"] + "requirements": ["openai==3.10.0", "python-open-router==0.4.0"] } diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 2ff1088ce1c99..67799727ed545 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -2,6 +2,7 @@ from pathlib import Path from types import MappingProxyType +from typing import Any, cast import openai from openai.types.images_response import ImagesResponse @@ -282,7 +283,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bo """Set up OpenAI Conversation from a config entry.""" client = openai.AsyncOpenAI( api_key=entry.data[CONF_API_KEY], - http_client=get_async_client(hass), + # Legacy HTTPX clients are supported at runtime only. + http_client=cast(Any, get_async_client(hass)), ) # Cache current platform data which gets added to each request @@ -290,7 +292,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bo _ = await hass.async_add_executor_job(client.platform_headers) try: - await hass.async_add_executor_job(client.with_options(timeout=10.0).models.list) + await client.models.list(timeout=10.0) except openai.AuthenticationError as err: raise ConfigEntryAuthFailed(err) from err except openai.OpenAIError as err: diff --git a/homeassistant/components/openai_conversation/ai_task.py b/homeassistant/components/openai_conversation/ai_task.py index e5ffa7516229d..eb3caff7cec96 100644 --- a/homeassistant/components/openai_conversation/ai_task.py +++ b/homeassistant/components/openai_conversation/ai_task.py @@ -140,7 +140,7 @@ async def _async_generate_image( else: mime_type = "image/png" - if hasattr(image_call, "size") and (size := image_call.size): + if size := image_call.size: width, height = tuple(size.split("x")) else: width, height = None, None diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 711d8e49db866..477769b964610 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -3,7 +3,7 @@ from collections.abc import Mapping import json import logging -from typing import Any, override +from typing import Any, cast, override import openai from probatio import to_openapi @@ -117,7 +117,9 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ client = openai.AsyncOpenAI( - api_key=data[CONF_API_KEY], http_client=get_async_client(hass) + api_key=data[CONF_API_KEY], + # Legacy HTTPX clients are supported at runtime only. + http_client=cast(Any, get_async_client(hass)), ) await client.models.list(timeout=10.0) @@ -651,7 +653,7 @@ async def _get_location_data(self) -> dict[str, str]: if zone_home is not None: client = openai.AsyncOpenAI( api_key=self._get_entry().data[CONF_API_KEY], - http_client=get_async_client(self.hass), + http_client=cast(Any, get_async_client(self.hass)), ) location_schema = vol.Schema( { diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index 95fb8fc7d211a..1af6e41921a01 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["openai==2.45.0"] + "requirements": ["openai==3.10.0"] } diff --git a/homeassistant/components/ovhcloud_ai_endpoints/__init__.py b/homeassistant/components/ovhcloud_ai_endpoints/__init__.py index 54f2250607874..83667c6c367d0 100644 --- a/homeassistant/components/ovhcloud_ai_endpoints/__init__.py +++ b/homeassistant/components/ovhcloud_ai_endpoints/__init__.py @@ -1,5 +1,7 @@ """The OVHcloud AI Endpoints integration.""" +from typing import Any, cast + from openai import ( AsyncOpenAI, AuthenticationError, @@ -27,7 +29,8 @@ def _create_client(hass: HomeAssistant, api_key: str) -> AsyncOpenAI: return AsyncOpenAI( base_url=BASE_URL, api_key=api_key, - http_client=get_async_client(hass), + # Legacy HTTPX clients are supported at runtime only. + http_client=cast(Any, get_async_client(hass)), ) diff --git a/homeassistant/components/ovhcloud_ai_endpoints/manifest.json b/homeassistant/components/ovhcloud_ai_endpoints/manifest.json index 93feba8048114..a1c56ee0592de 100644 --- a/homeassistant/components/ovhcloud_ai_endpoints/manifest.json +++ b/homeassistant/components/ovhcloud_ai_endpoints/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["openai==2.45.0"] + "requirements": ["openai==3.10.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index edcb7114de95d..17f13279796bd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -47,7 +47,7 @@ ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.4.1 mutagen==1.48.1 -openai==2.45.0 +openai==3.10.0 orjson==3.11.9 packaging>=23.1 paho-mqtt==2.1.0 diff --git a/requirements_all.txt b/requirements_all.txt index 57b5c23dc5481..1b13211612549 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1821,7 +1821,7 @@ open-meteo==0.3.2 # homeassistant.components.open_router # homeassistant.components.openai_conversation # homeassistant.components.ovhcloud_ai_endpoints -openai==2.45.0 +openai==3.10.0 # homeassistant.components.openerz openerz-api==0.3.0 diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index 22a18743394b6..06a6b316765de 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -1,6 +1,6 @@ """Tests helpers.""" -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -122,13 +122,15 @@ async def mock_config_entry_with_reasoning_model( @pytest.fixture async def mock_init_component( hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: +) -> AsyncGenerator[None]: """Initialize integration.""" with patch( "openai.resources.models.AsyncModels.list", + new_callable=AsyncMock, ): assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() + yield @pytest.fixture(autouse=True) diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 9a09697af7a93..06651c6d2d5c5 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -277,12 +277,13 @@ async def test_init_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture, - side_effect, - error, + side_effect: APIConnectionError | BadRequestError, + error: str, ) -> None: """Test initialization errors.""" with patch( "openai.resources.models.AsyncModels.list", + new_callable=AsyncMock, side_effect=side_effect, ): assert await async_setup_component(hass, DOMAIN, {}) @@ -298,6 +299,7 @@ async def test_init_auth_error( """Test auth error during init errors.""" with patch( "openai.resources.models.AsyncModels.list", + new_callable=AsyncMock, side_effect=AuthenticationError( response=httpx.Response( status_code=500, request=httpx.Request(method="GET", url="test") From 29ce6d0ea4d0dafe5f990f618456cc71c892ba41 Mon Sep 17 00:00:00 2001 From: rqi14 <26152437+rqi14@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:09:00 +0800 Subject: [PATCH 10/18] Nest climate: recompute supported_features from live device traits (#181660) --- homeassistant/components/nest/climate.py | 7 +++- tests/components/nest/test_climate.py | 49 ++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index 444c70fc0f209..da3489ac74b80 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -126,7 +126,6 @@ def available(self) -> bool: @override async def async_added_to_hass(self) -> None: """Run when entity is added to register update signal handler.""" - self._attr_supported_features = self._get_supported_features() self.async_on_remove( self._device.add_update_listener(self.async_write_ha_state) ) @@ -264,6 +263,12 @@ def fan_modes(self) -> list[str]: return FAN_INV_MODES return [] + @property + @override + def supported_features(self) -> ClimateEntityFeature: + """Return the bitmap of supported features, computed from current traits.""" + return self._get_supported_features() + def _get_supported_features(self) -> ClimateEntityFeature: """Compute the bitmap of supported features from the current state.""" features = ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON diff --git a/tests/components/nest/test_climate.py b/tests/components/nest/test_climate.py index e45fc5752d722..09afe6ee814b1 100644 --- a/tests/components/nest/test_climate.py +++ b/tests/components/nest/test_climate.py @@ -1372,6 +1372,55 @@ async def test_thermostat_fan_empty( assert ATTR_FAN_MODES not in thermostat.attributes +async def test_thermostat_fan_becomes_supported_after_update( + hass: HomeAssistant, + setup_platform: PlatformSetup, + create_device: CreateDevice, + create_event: CreateEvent, +) -> None: + """Test that supported_features is recomputed once the fan trait reports a mode. + + The fan trait can be present at startup without a timer_mode value yet + populated (e.g. before the initial full state has propagated). Regression + test for supported_features being frozen at entity setup rather than + reflecting the live device state. + """ + create_device.create( + { + "sdm.devices.traits.Fan": {}, + "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "OFF", + }, + } + ) + await setup_platform() + + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert ATTR_FAN_MODE not in thermostat.attributes + assert ATTR_FAN_MODES not in thermostat.attributes + assert not ( + thermostat.attributes[ATTR_SUPPORTED_FEATURES] & ClimateEntityFeature.FAN_MODE + ) + + # The fan trait later reports a real timer_mode value + await create_event( + { + "sdm.devices.traits.Fan": {"timerMode": "OFF"}, + } + ) + + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert ( + thermostat.attributes[ATTR_SUPPORTED_FEATURES] & ClimateEntityFeature.FAN_MODE + ) + assert thermostat.attributes[ATTR_FAN_MODE] == FAN_OFF + assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] + + async def test_thermostat_invalid_fan_mode( hass: HomeAssistant, setup_platform: PlatformSetup, From 3521db15fa3b4427b97983a08d2cf5fbf2aaf47a Mon Sep 17 00:00:00 2001 From: Anthony <210036686+Herbertmt978@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:09:46 +0100 Subject: [PATCH 11/18] Discover newly shared ScorpionTrack vehicles (#181659) --- .../components/scorpiontrack/binary_sensor.py | 22 ++- .../scorpiontrack/device_tracker.py | 22 ++- .../scorpiontrack/quality_scale.yaml | 2 +- .../components/scorpiontrack/sensor.py | 24 +++- .../scorpiontrack/test_device_tracker.py | 18 ++- tests/components/scorpiontrack/test_init.py | 132 +++++++++++++++++- 6 files changed, 197 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/scorpiontrack/binary_sensor.py b/homeassistant/components/scorpiontrack/binary_sensor.py index 6ef69e78bea7f..0f89ca2630480 100644 --- a/homeassistant/components/scorpiontrack/binary_sensor.py +++ b/homeassistant/components/scorpiontrack/binary_sensor.py @@ -3,7 +3,7 @@ from typing import override from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ScorpionTrackConfigEntry, ScorpionTrackCoordinator @@ -19,10 +19,22 @@ async def async_setup_entry( ) -> None: """Set up ScorpionTrack ignition binary sensors.""" coordinator = entry.runtime_data - async_add_entities( - ScorpionTrackIgnitionBinarySensor(coordinator, vehicle.id) - for vehicle in coordinator.data.vehicles - ) + known_vehicles: set[int] = set() + + @callback + def async_add_new_vehicles() -> None: + """Add ignition sensors for vehicles newly included in the share.""" + new_vehicles = coordinator.vehicles_by_id.keys() - known_vehicles + if not new_vehicles: + return + known_vehicles.update(new_vehicles) + async_add_entities( + ScorpionTrackIgnitionBinarySensor(coordinator, vehicle_id) + for vehicle_id in new_vehicles + ) + + async_add_new_vehicles() + entry.async_on_unload(coordinator.async_add_listener(async_add_new_vehicles)) class ScorpionTrackIgnitionBinarySensor(ScorpionTrackEntity, BinarySensorEntity): diff --git a/homeassistant/components/scorpiontrack/device_tracker.py b/homeassistant/components/scorpiontrack/device_tracker.py index 723f7b585f8ba..c8e6b1923a962 100644 --- a/homeassistant/components/scorpiontrack/device_tracker.py +++ b/homeassistant/components/scorpiontrack/device_tracker.py @@ -5,7 +5,7 @@ from pyscorpiontrack import ScorpionTrackVehicle from homeassistant.components.device_tracker import TrackerEntity -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ScorpionTrackConfigEntry, ScorpionTrackCoordinator @@ -21,10 +21,22 @@ async def async_setup_entry( ) -> None: """Set up ScorpionTrack tracker entities.""" coordinator = entry.runtime_data - async_add_entities( - ScorpionTrackTrackerEntity(coordinator, vehicle.id) - for vehicle in coordinator.data.vehicles - ) + known_vehicles: set[int] = set() + + @callback + def async_add_new_vehicles() -> None: + """Add trackers for vehicles newly included in the share.""" + new_vehicles = coordinator.vehicles_by_id.keys() - known_vehicles + if not new_vehicles: + return + known_vehicles.update(new_vehicles) + async_add_entities( + ScorpionTrackTrackerEntity(coordinator, vehicle_id) + for vehicle_id in new_vehicles + ) + + async_add_new_vehicles() + entry.async_on_unload(coordinator.async_add_listener(async_add_new_vehicles)) class ScorpionTrackTrackerEntity(ScorpionTrackEntity, TrackerEntity): diff --git a/homeassistant/components/scorpiontrack/quality_scale.yaml b/homeassistant/components/scorpiontrack/quality_scale.yaml index a41fb6e77f828..23b86489161d4 100644 --- a/homeassistant/components/scorpiontrack/quality_scale.yaml +++ b/homeassistant/components/scorpiontrack/quality_scale.yaml @@ -74,7 +74,7 @@ rules: docs-supported-functions: done docs-troubleshooting: done docs-use-cases: todo - dynamic-devices: todo + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done diff --git a/homeassistant/components/scorpiontrack/sensor.py b/homeassistant/components/scorpiontrack/sensor.py index 286e9b59af47f..1dde86826c37a 100644 --- a/homeassistant/components/scorpiontrack/sensor.py +++ b/homeassistant/components/scorpiontrack/sensor.py @@ -14,7 +14,7 @@ SensorStateClass, ) from homeassistant.const import DEGREE, EntityCategory, UnitOfSpeed -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -74,11 +74,23 @@ async def async_setup_entry( ) -> None: """Set up ScorpionTrack sensors.""" coordinator = entry.runtime_data - async_add_entities( - ScorpionTrackSensor(coordinator, vehicle.id, entity_description) - for vehicle in coordinator.data.vehicles - for entity_description in SENSORS - ) + known_vehicles: set[int] = set() + + @callback + def async_add_new_vehicles() -> None: + """Add sensors for vehicles newly included in the share.""" + new_vehicles = coordinator.vehicles_by_id.keys() - known_vehicles + if not new_vehicles: + return + known_vehicles.update(new_vehicles) + async_add_entities( + ScorpionTrackSensor(coordinator, vehicle_id, entity_description) + for vehicle_id in new_vehicles + for entity_description in SENSORS + ) + + async_add_new_vehicles() + entry.async_on_unload(coordinator.async_add_listener(async_add_new_vehicles)) class ScorpionTrackSensor(ScorpionTrackEntity, SensorEntity): diff --git a/tests/components/scorpiontrack/test_device_tracker.py b/tests/components/scorpiontrack/test_device_tracker.py index 22e8c8b77d3d4..6a06881d3ea69 100644 --- a/tests/components/scorpiontrack/test_device_tracker.py +++ b/tests/components/scorpiontrack/test_device_tracker.py @@ -5,10 +5,11 @@ from freezegun.api import FrozenDateTimeFactory from pyscorpiontrack import ScorpionTrackConnectionError, ScorpionTrackShare +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.scorpiontrack.const import DEFAULT_SCAN_INTERVAL -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_NOT_HOME, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -75,14 +76,20 @@ async def test_connection_error_makes_tracker_unavailable( assert state.state == STATE_UNAVAILABLE -async def test_new_vehicles_after_setup_do_not_add_tracker_entities( +@pytest.mark.parametrize( + ("latitude", "expected_state"), + [(51.5074, STATE_NOT_HOME), (None, STATE_UNAVAILABLE)], +) +async def test_new_vehicle_tracker_availability( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_config_entry: MockConfigEntry, mock_share: ScorpionTrackShare, mock_scorpiontrack_client: AsyncMock, + latitude: float | None, + expected_state: str, ) -> None: - """Vehicles that appear later should wait for a future dynamic-device PR.""" + """Test a newly discovered tracker still requires coordinates.""" await setup_integration(hass, mock_config_entry) new_vehicle = replace( @@ -91,6 +98,7 @@ async def test_new_vehicles_after_setup_do_not_add_tracker_entities( name="Tiguan", registration="EF34 ABC", model="Tiguan", + position=replace(mock_share.vehicles[0].position, latitude=latitude), ) mock_scorpiontrack_client.async_get_share.return_value = replace( mock_share, vehicles=(*mock_share.vehicles, new_vehicle) @@ -99,4 +107,6 @@ async def test_new_vehicles_after_setup_do_not_add_tracker_entities( async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("device_tracker.ef34_abc") is None + state = hass.states.get("device_tracker.ef34_abc") + assert state is not None + assert state.state == expected_state diff --git a/tests/components/scorpiontrack/test_init.py b/tests/components/scorpiontrack/test_init.py index ee5b059acb97d..e1c048981229c 100644 --- a/tests/components/scorpiontrack/test_init.py +++ b/tests/components/scorpiontrack/test_init.py @@ -1,21 +1,26 @@ """Test ScorpionTrack integration setup.""" +from dataclasses import replace from unittest.mock import AsyncMock +from freezegun.api import FrozenDateTimeFactory from pyscorpiontrack import ( ScorpionTrackConnectionError, ScorpionTrackInvalidTokenError, + ScorpionTrackShare, ScorpionTrackShareUnavailableError, ) import pytest +from homeassistant.components.scorpiontrack.const import DEFAULT_SCAN_INTERVAL from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_setup_entry( @@ -74,3 +79,126 @@ async def test_setup_entry_errors( await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is expected_state + + +@pytest.mark.parametrize( + "entity_id", + [ + "device_tracker.xy34_abc", + "sensor.xy34_abc_speed", + "sensor.xy34_abc_last_reported", + "binary_sensor.xy34_abc_ignition", + ], +) +async def test_vehicle_added_after_setup( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_share: ScorpionTrackShare, + mock_scorpiontrack_client: AsyncMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + entity_id: str, +) -> None: + """Test discovery, repeat updates, returning vehicles, and reload.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get(entity_id) is None + initial_entities = len( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + ) + + added_vehicle = replace(mock_share.vehicles[0], id=2, registration="XY34 ABC") + updated_share = replace(mock_share, vehicles=(*mock_share.vehicles, added_vehicle)) + mock_scorpiontrack_client.async_get_share.return_value = updated_share + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state != STATE_UNAVAILABLE + registry_entry = entity_registry.async_get(entity_id) + assert registry_entry is not None + device = device_registry.async_get_device_by_identifier( + ("scorpiontrack", "101_2"), mock_config_entry.entry_id + ) + assert device is not None + assert registry_entry.device_id == device.id + heading = entity_registry.async_get("sensor.xy34_abc_heading") + assert heading is not None + assert heading.device_id == device.id + assert heading.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(heading.entity_id) is None + assert mock_scorpiontrack_client.async_get_share.await_count == 2 + + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + len( + er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + ) + == initial_entities * 2 + ) + + mock_scorpiontrack_client.async_get_share.return_value = mock_share + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + mock_scorpiontrack_client.async_get_share.return_value = updated_share + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == state.state + assert entity_registry.async_get(entity_id).id == registry_entry.id + assert ( + len( + er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + ) + == initial_entities * 2 + ) + + assert await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == state.state + assert entity_registry.async_get(entity_id).id == registry_entry.id + + +async def test_discovery_stops_on_unload( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_share: ScorpionTrackShare, + mock_scorpiontrack_client: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test unloading removes the discovery listeners.""" + await setup_integration(hass, mock_config_entry) + initial_entities = len( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + ) + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + added_vehicle = replace(mock_share.vehicles[0], id=2, registration="XY34 ABC") + mock_scorpiontrack_client.async_get_share.return_value = replace( + mock_share, vehicles=(*mock_share.vehicles, added_vehicle) + ) + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_scorpiontrack_client.async_get_share.assert_awaited_once_with() + assert ( + len( + er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + ) + == initial_entities + ) From 86501aa47381724038a98d23a046e88663cb59b8 Mon Sep 17 00:00:00 2001 From: Oleksii Date: Thu, 10 Sep 2026 15:11:18 +0300 Subject: [PATCH 12/18] Add air alert levels to Ukraine Alarm (#181558) Co-authored-by: Claude Opus 5 (1M context) --- .../components/ukraine_alarm/binary_sensor.py | 14 +- .../components/ukraine_alarm/const.py | 9 ++ .../components/ukraine_alarm/coordinator.py | 8 +- .../components/ukraine_alarm/icons.json | 6 + .../components/ukraine_alarm/strings.json | 6 + .../ukraine_alarm/test_binary_sensor.py | 124 ++++++++++++++++++ 6 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 tests/components/ukraine_alarm/test_binary_sensor.py diff --git a/homeassistant/components/ukraine_alarm/binary_sensor.py b/homeassistant/components/ukraine_alarm/binary_sensor.py index e7b9bb70b9f76..69a8081d76114 100644 --- a/homeassistant/components/ukraine_alarm/binary_sensor.py +++ b/homeassistant/components/ukraine_alarm/binary_sensor.py @@ -15,6 +15,8 @@ from .const import ( ALERT_TYPE_AIR, + ALERT_TYPE_AIR_RED, + ALERT_TYPE_AIR_YELLOW, ALERT_TYPE_ARTILLERY, ALERT_TYPE_CHEMICAL, ALERT_TYPE_NUCLEAR, @@ -37,6 +39,16 @@ translation_key="air", device_class=BinarySensorDeviceClass.SAFETY, ), + BinarySensorEntityDescription( + key=ALERT_TYPE_AIR_RED, + translation_key="air_red", + device_class=BinarySensorDeviceClass.SAFETY, + ), + BinarySensorEntityDescription( + key=ALERT_TYPE_AIR_YELLOW, + translation_key="air_yellow", + device_class=BinarySensorDeviceClass.SAFETY, + ), BinarySensorEntityDescription( key=ALERT_TYPE_URBAN_FIGHTS, translation_key="urban_fights", @@ -113,4 +125,4 @@ def __init__( @override def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" - return self.coordinator.data.get(self.entity_description.key, None) + return self.coordinator.data[self.entity_description.key] diff --git a/homeassistant/components/ukraine_alarm/const.py b/homeassistant/components/ukraine_alarm/const.py index a6006b1115546..539bd0135ec6d 100644 --- a/homeassistant/components/ukraine_alarm/const.py +++ b/homeassistant/components/ukraine_alarm/const.py @@ -7,6 +7,8 @@ MANUFACTURER = "Ukraine Alarm" ALERT_TYPE_UNKNOWN = "UNKNOWN" ALERT_TYPE_AIR = "AIR" +ALERT_TYPE_AIR_RED = "AIR_RED" +ALERT_TYPE_AIR_YELLOW = "AIR_YELLOW" ALERT_TYPE_ARTILLERY = "ARTILLERY" ALERT_TYPE_URBAN_FIGHTS = "URBAN_FIGHTS" ALERT_TYPE_CHEMICAL = "CHEMICAL" @@ -14,9 +16,16 @@ ALERT_TYPES = { ALERT_TYPE_UNKNOWN, ALERT_TYPE_AIR, + ALERT_TYPE_AIR_RED, + ALERT_TYPE_AIR_YELLOW, ALERT_TYPE_ARTILLERY, ALERT_TYPE_URBAN_FIGHTS, ALERT_TYPE_CHEMICAL, ALERT_TYPE_NUCLEAR, } +# The API attaches levels to all alert types, but they are only meaningful for AIR. +AIR_ALERT_LEVELS = { + "red": ALERT_TYPE_AIR_RED, + "yellow": ALERT_TYPE_AIR_YELLOW, +} PLATFORMS = [Platform.BINARY_SENSOR] diff --git a/homeassistant/components/ukraine_alarm/coordinator.py b/homeassistant/components/ukraine_alarm/coordinator.py index ac088f63da48f..234d464fe70be 100644 --- a/homeassistant/components/ukraine_alarm/coordinator.py +++ b/homeassistant/components/ukraine_alarm/coordinator.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ALERT_TYPES, DOMAIN +from .const import AIR_ALERT_LEVELS, ALERT_TYPE_AIR, ALERT_TYPES, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -57,4 +57,10 @@ async def _async_update_data(self) -> dict[str, Any]: for alert in res[0]["activeAlerts"]: current[alert["type"]] = True + if alert["type"] != ALERT_TYPE_AIR: + continue + for level in alert.get("activeAlertLevels") or []: + if key := AIR_ALERT_LEVELS.get(str(level.get("alertLevel")).lower()): + current[key] = True + return current diff --git a/homeassistant/components/ukraine_alarm/icons.json b/homeassistant/components/ukraine_alarm/icons.json index 8b4b6f666fd5c..0148aade8048b 100644 --- a/homeassistant/components/ukraine_alarm/icons.json +++ b/homeassistant/components/ukraine_alarm/icons.json @@ -4,6 +4,12 @@ "air": { "default": "mdi:cloud" }, + "air_red": { + "default": "mdi:cloud-alert" + }, + "air_yellow": { + "default": "mdi:cloud-alert-outline" + }, "artillery": { "default": "mdi:tank" }, diff --git a/homeassistant/components/ukraine_alarm/strings.json b/homeassistant/components/ukraine_alarm/strings.json index 5a586c19fb8c9..12ba216225b55 100644 --- a/homeassistant/components/ukraine_alarm/strings.json +++ b/homeassistant/components/ukraine_alarm/strings.json @@ -34,6 +34,12 @@ "air": { "name": "Air" }, + "air_red": { + "name": "Air (red)" + }, + "air_yellow": { + "name": "Air (yellow)" + }, "artillery": { "name": "Artillery" }, diff --git a/tests/components/ukraine_alarm/test_binary_sensor.py b/tests/components/ukraine_alarm/test_binary_sensor.py new file mode 100644 index 0000000000000..ec546ee6e4cdb --- /dev/null +++ b/tests/components/ukraine_alarm/test_binary_sensor.py @@ -0,0 +1,124 @@ +"""Test the Ukraine Alarm binary sensors.""" + +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components.ukraine_alarm.const import DOMAIN +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + +REGION_ID = "2.1" + +ALL_KEYS = ( + "UNKNOWN", + "AIR", + "AIR_RED", + "AIR_YELLOW", + "ARTILLERY", + "URBAN_FIGHTS", + "CHEMICAL", + "NUCLEAR", +) + + +async def setup_integration( + hass: HomeAssistant, active_alerts: list[dict[str, Any]] +) -> None: + """Set up the integration against a stubbed region payload.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + data={"region": REGION_ID, "name": f"District {REGION_ID}"}, + unique_id=REGION_ID, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.ukraine_alarm.coordinator.Client.get_alerts", + return_value=[{"activeAlerts": active_alerts}], + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + +def _state(hass: HomeAssistant, entity_registry: er.EntityRegistry, key: str) -> State: + """Return the state of the binary sensor for an alert type. + + Entity ids are derived from translated names, which are generated from + strings.json at build time and so are not available in a plain checkout. + """ + entity_id = entity_registry.async_get_entity_id( + "binary_sensor", DOMAIN, f"{REGION_ID}-{key}".lower() + ) + assert entity_id is not None + state = hass.states.get(entity_id) + assert state is not None + return state + + +def _level(alert_level: str) -> dict[str, Any]: + """Build one reported alert level as the API serves it.""" + return { + "alertLevel": alert_level, + "reason": "Ракетна загроза (червоний рівень)", + "createdAt": "2026-09-08T11:40:15.440927Z", + } + + +def _alert( + alert_type: str = "AIR", levels: list[dict[str, Any]] | None = None +) -> dict[str, Any]: + """Build an active alert, optionally carrying alert levels.""" + alert: dict[str, Any] = {"type": alert_type} + if levels is not None: + alert["activeAlertLevels"] = levels + return alert + + +@pytest.mark.parametrize( + ("active_alerts", "expected_on"), + [ + ([], set()), + # An air alert without levels keeps working as it did before. + ([_alert()], {"AIR"}), + ([_alert(levels=[])], {"AIR"}), + ([_alert(levels=[_level("Red")])], {"AIR", "AIR_RED"}), + ([_alert(levels=[_level("Yellow")])], {"AIR", "AIR_YELLOW"}), + # One air alert can report both levels at the same time. + ( + [_alert(levels=[_level("Red"), _level("Yellow")])], + {"AIR", "AIR_RED", "AIR_YELLOW"}, + ), + # So can two air alerts that are active for the region at once. + ( + [_alert(levels=[_level("Yellow")]), _alert(levels=[_level("Red")])], + {"AIR", "AIR_RED", "AIR_YELLOW"}, + ), + # The API attaches levels to all alert types, but they are only + # meaningful for AIR, so the other types are left alone. + ([_alert("ARTILLERY", [_level("Red")])], {"ARTILLERY"}), + ([_alert("URBAN_FIGHTS", [_level("Red")])], {"URBAN_FIGHTS"}), + # A level the integration does not know must not break the update. + ([_alert(levels=[_level("Purple")])], {"AIR"}), + ( + [_alert("AIR", [_level("Red")]), _alert("NUCLEAR")], + {"AIR", "AIR_RED", "NUCLEAR"}, + ), + ], +) +async def test_alerts( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + active_alerts: list[dict[str, Any]], + expected_on: set[str], +) -> None: + """Test each alert turns on exactly the binary sensors it maps to.""" + await setup_integration(hass, active_alerts) + + for key in ALL_KEYS: + state = _state(hass, entity_registry, key) + assert state.state == ("on" if key in expected_on else "off"), key From 4302e921efec6fae687d03131930e08e533fb61e Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Thu, 10 Sep 2026 14:13:59 +0200 Subject: [PATCH 13/18] Defer the next ViCare refresh to the API quota reset (#181634) --- .../components/vicare/coordinator.py | 10 +- homeassistant/components/vicare/utils.py | 14 +++ tests/components/vicare/test_init.py | 107 ++++++++++++++++++ tests/components/vicare/test_utils.py | 42 ++++++- 4 files changed, 171 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vicare/coordinator.py b/homeassistant/components/vicare/coordinator.py index 3661bf6e63bf8..534c8ff8054ce 100644 --- a/homeassistant/components/vicare/coordinator.py +++ b/homeassistant/components/vicare/coordinator.py @@ -22,6 +22,7 @@ from .const import DEFAULT_CACHE_DURATION, DOMAIN from .types import ViCareConfigEntry +from .utils import retry_after_from _LOGGER = logging.getLogger(__name__) @@ -73,11 +74,18 @@ def _refresh(self) -> None: ) except PyViCareInvalidCredentialsError as err: raise ConfigEntryAuthFailed from err + except PyViCareRateLimitError as err: + raise UpdateFailed( + str(err), + retry_after=retry_after_from( + err, + self.update_interval or timedelta(seconds=DEFAULT_CACHE_DURATION), + ), + ) from err except ( PyViCareDeviceCommunicationError, PyViCareInternalServerError, PyViCareInvalidDataError, - PyViCareRateLimitError, requests.RequestException, ) as err: raise UpdateFailed(str(err)) from err diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py index 3056e9b9d8485..f10b20f90b9b6 100644 --- a/homeassistant/components/vicare/utils.py +++ b/homeassistant/components/vicare/utils.py @@ -1,6 +1,7 @@ """ViCare helpers functions.""" from collections.abc import Callable, Mapping +from datetime import UTC, timedelta import logging from typing import Any @@ -21,9 +22,12 @@ from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import STORAGE_DIR +from homeassistant.util import dt as dt_util from .const import DEFAULT_CACHE_DURATION, VICARE_TOKEN_FILENAME +MAX_RATE_LIMIT_BACKOFF = 86400 # the quota window is a day + _LOGGER = logging.getLogger(__name__) @@ -157,3 +161,13 @@ def filter_state(state: str) -> str | None: def normalize_state(state: str) -> str: """Return the state with underscores instead of hyphens.""" return state.replace("-", "_") + + +def retry_after_from(error: PyViCareRateLimitError, floor: timedelta) -> float: + """Return seconds to wait after a rate limit, clamped to [floor, one day]. + + limitResetDate is naive UTC and can be in the past. + """ + reset = error.limitResetDate.replace(tzinfo=UTC) + delay = (reset - dt_util.utcnow()).total_seconds() + return min(max(delay, floor.total_seconds()), MAX_RATE_LIMIT_BACKOFF) diff --git a/tests/components/vicare/test_init.py b/tests/components/vicare/test_init.py index 28899ea0dc877..ba59aa7ae1aff 100644 --- a/tests/components/vicare/test_init.py +++ b/tests/components/vicare/test_init.py @@ -40,6 +40,11 @@ from tests.common import MockConfigEntry, async_fire_time_changed +# From a real rate limit response: 2026-09-09T00:00:04.144Z. +QUOTA_RESET_MS = 1788912004144 + +SENSOR_ID = "sensor.model0_outside_temperature" + # 16-character zigbee IEEE address shared by the FHT fixtures. ZIGBEE_IEEE = "#" * 16 @@ -311,6 +316,108 @@ async def test_setup_entry_transient_error( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_coordinator_backs_off_until_the_quota_resets( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a rate limited refresh defers the next one to the reset time.""" + freezer.move_to("2026-09-08 20:00:04+00:00") + fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] + mock_vicare = MockPyViCare(fixtures) + service = mock_vicare.devices[0].service + + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + ), + patch( + f"{MODULE}._setup_vicare_api", + return_value=mock_vicare.as_vicare_data(), + ), + ): + await setup_integration(hass, mock_config_entry) + + service.fetch_all_features.side_effect = PyViCareRateLimitError( + { + "extendedPayload": { + "name": "development portal", + "requestCountLimit": 1450, + "limitReset": QUOTA_RESET_MS, + } + } + ) + freezer.tick(timedelta(seconds=DEFAULT_CACHE_DURATION * 2)) + async_fire_time_changed(hass, fire_all=True) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(SENSOR_ID).state == STATE_UNAVAILABLE + calls = service.fetch_all_features.call_count + + # The quota resets four hours out, so nothing may go out at the ordinary + # interval in between. + freezer.tick(timedelta(seconds=DEFAULT_CACHE_DURATION * 2)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert service.fetch_all_features.call_count == calls + + freezer.tick(timedelta(hours=4)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert service.fetch_all_features.call_count > calls + + +async def test_coordinator_backs_off_when_the_reset_has_passed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a reset time in the past still defers, instead of retrying at once.""" + freezer.move_to("2026-09-09 01:00:04+00:00") + fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] + mock_vicare = MockPyViCare(fixtures) + service = mock_vicare.devices[0].service + + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + ), + patch( + f"{MODULE}._setup_vicare_api", + return_value=mock_vicare.as_vicare_data(), + ), + ): + await setup_integration(hass, mock_config_entry) + + service.fetch_all_features.side_effect = PyViCareRateLimitError( + { + "extendedPayload": { + "name": "development portal", + "requestCountLimit": 1450, + "limitReset": QUOTA_RESET_MS, + } + } + ) + freezer.tick(timedelta(seconds=DEFAULT_CACHE_DURATION * 2)) + async_fire_time_changed(hass, fire_all=True) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(SENSOR_ID).state == STATE_UNAVAILABLE + calls = service.fetch_all_features.call_count + + # A zero delay would reschedule at once and hammer a quota that is still + # spent, so the wait stays at the ordinary interval. + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert service.fetch_all_features.call_count == calls + + freezer.tick(timedelta(seconds=DEFAULT_CACHE_DURATION * 2)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert service.fetch_all_features.call_count > calls + + async def test_setup_entry_invalid_credentials( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/vicare/test_utils.py b/tests/components/vicare/test_utils.py index 13ca77f279242..a92f11d2d926a 100644 --- a/tests/components/vicare/test_utils.py +++ b/tests/components/vicare/test_utils.py @@ -1,8 +1,15 @@ """Test ViCare utils.""" +from datetime import timedelta + +from freezegun.api import FrozenDateTimeFactory import pytest +from PyViCare.PyViCareUtils import PyViCareRateLimitError + +from homeassistant.components.vicare.utils import filter_state, retry_after_from -from homeassistant.components.vicare.utils import filter_state +# From a real rate limit response: 2026-09-09T00:00:04.144Z. +QUOTA_RESET_MS = 1788912004144 @pytest.mark.parametrize( @@ -21,3 +28,36 @@ async def test_filter_state( """Test filter_state.""" assert filter_state(state) == expected_result + + +@pytest.mark.parametrize( + ("now", "floor", "expected_result"), + [ + ("2026-09-08 20:00:04+00:00", timedelta(seconds=60), 14400.144), + # A reset already passed must not retry at once. + ("2026-09-09 01:00:04+00:00", timedelta(seconds=60), 60), + # The floor is the caller's interval, not a fixed minute. + ("2026-09-09 01:00:04+00:00", timedelta(seconds=180), 180), + # Nothing waits longer than the quota window. + ("2026-09-06 00:00:04+00:00", timedelta(seconds=60), 86400), + ], +) +async def test_retry_after_from( + freezer: FrozenDateTimeFactory, + now: str, + floor: timedelta, + expected_result: float, +) -> None: + """Test the rate limit backoff is clamped to the caller's interval and a day.""" + freezer.move_to(now) + error = PyViCareRateLimitError( + { + "extendedPayload": { + "name": "development portal", + "requestCountLimit": 1450, + "limitReset": QUOTA_RESET_MS, + } + } + ) + + assert retry_after_from(error, floor) == expected_result From 06206cfa02c1488a3685f620f68b5456a7da6941 Mon Sep 17 00:00:00 2001 From: Yoav Mor Date: Thu, 10 Sep 2026 15:53:39 +0300 Subject: [PATCH 14/18] Add Besen charging current control (#180617) --- homeassistant/components/besen/const.py | 2 +- homeassistant/components/besen/coordinator.py | 5 + homeassistant/components/besen/number.py | 62 ++++++ homeassistant/components/besen/strings.json | 3 + tests/components/besen/conftest.py | 14 +- .../besen/snapshots/test_number.ambr | 62 ++++++ tests/components/besen/test_number.py | 207 ++++++++++++++++++ 7 files changed, 353 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/besen/number.py create mode 100644 tests/components/besen/snapshots/test_number.ambr create mode 100644 tests/components/besen/test_number.py diff --git a/homeassistant/components/besen/const.py b/homeassistant/components/besen/const.py index 052084ab68f14..8e9b915920667 100644 --- a/homeassistant/components/besen/const.py +++ b/homeassistant/components/besen/const.py @@ -7,4 +7,4 @@ DOMAIN: Final = "besen" NAME: Final = "Besen" -PLATFORMS: Final = [Platform.SENSOR, Platform.SWITCH] +PLATFORMS: Final = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] diff --git a/homeassistant/components/besen/coordinator.py b/homeassistant/components/besen/coordinator.py index b2002dde2002c..841a884f81345 100644 --- a/homeassistant/components/besen/coordinator.py +++ b/homeassistant/components/besen/coordinator.py @@ -90,6 +90,11 @@ async def async_stop_charging(self) -> None: await self._async_run_command(self.client.async_stop_charging()) + async def async_set_charge_amps(self, amps: int) -> None: + """Set the charging current.""" + + await self._async_run_command(self.client.async_set_charge_amps(amps)) + async def _async_run_command(self, command: Awaitable[None]) -> None: """Run a charger command and translate command failures.""" diff --git a/homeassistant/components/besen/number.py b/homeassistant/components/besen/number.py new file mode 100644 index 0000000000000..94c8024e23ac1 --- /dev/null +++ b/homeassistant/components/besen/number.py @@ -0,0 +1,62 @@ +"""Number platform for Besen.""" + +from typing import override + +from besen.const import FALLBACK_MAX_CHARGE_AMPS, MIN_CHARGE_AMPS + +from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode +from homeassistant.const import EntityCategory, UnitOfElectricCurrent +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BesenConfigEntry +from .coordinator import BesenCoordinator +from .entity import BesenEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BesenConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Besen number platform.""" + + async_add_entities([BesenChargingCurrentNumber(entry.runtime_data)]) + + +class BesenChargingCurrentNumber(BesenEntity, NumberEntity): + """Charging current control.""" + + _attr_device_class = NumberDeviceClass.CURRENT + _attr_entity_category = EntityCategory.CONFIG + _attr_mode = NumberMode.BOX + _attr_native_min_value = MIN_CHARGE_AMPS + _attr_native_step = 1 + _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE + + def __init__(self, coordinator: BesenCoordinator) -> None: + """Initialize the charging current control.""" + + super().__init__(coordinator, "charging_current") + + @property + @override + def native_max_value(self) -> float: + """Return the maximum charging current.""" + + return self.coordinator.data.info.output_max_amps or FALLBACK_MAX_CHARGE_AMPS + + @property + @override + def native_value(self) -> float | None: + """Return the configured charging current.""" + + return self.coordinator.data.config.charge_amps + + @override + async def async_set_native_value(self, value: float) -> None: + """Set the charging current.""" + + await self.coordinator.async_set_charge_amps(int(value)) diff --git a/homeassistant/components/besen/strings.json b/homeassistant/components/besen/strings.json index b7345780d0988..a9508a9df882e 100644 --- a/homeassistant/components/besen/strings.json +++ b/homeassistant/components/besen/strings.json @@ -38,6 +38,9 @@ } }, "entity": { + "number": { + "charging_current": { "name": "Charging current" } + }, "sensor": { "charging_power": { "name": "Charging power" }, "external_temperature": { "name": "External temperature" }, diff --git a/tests/components/besen/conftest.py b/tests/components/besen/conftest.py index 9cc0a1ffa34dd..9de1e42b0e076 100644 --- a/tests/components/besen/conftest.py +++ b/tests/components/besen/conftest.py @@ -50,6 +50,8 @@ def charger_state( *, charger_status: bool | None = True, + charge_amps: int | None = 16, + output_max_amps: int | None = 32, available: bool = True, authenticated: bool = True, phases: int = 1, @@ -66,8 +68,13 @@ def charger_state( model="BS20", hardware_version="HW1", software_version="SW1", + output_max_amps=output_max_amps, + ), + config=ChargerConfig( + charge_amps=charge_amps, + device_name="Garage", + rssi=-55, ), - config=ChargerConfig(device_name="Garage", rssi=-55), charge=( charge if charge is not None @@ -100,6 +107,7 @@ def _configure_client_mock(client: Mock) -> None: client.async_stop = AsyncMock() client.async_start_charging = AsyncMock() client.async_stop_charging = AsyncMock() + client.async_set_charge_amps = AsyncMock() client.add_listener.return_value = Mock() @@ -157,8 +165,12 @@ async def async_start_charging() -> None: async def async_stop_charging() -> None: publish_besen_state(client, charger_state(charger_status=False)) + async def async_set_charge_amps(amps: int) -> None: + publish_besen_state(client, charger_state(charge_amps=amps)) + client.async_start_charging.side_effect = async_start_charging client.async_stop_charging.side_effect = async_stop_charging + client.async_set_charge_amps.side_effect = async_set_charge_amps yield client diff --git a/tests/components/besen/snapshots/test_number.ambr b/tests/components/besen/snapshots/test_number.ambr new file mode 100644 index 0000000000000..243e60b9c3cac --- /dev/null +++ b/tests/components/besen/snapshots/test_number.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_number_state[number.garage_charging_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 32, + : 6, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.garage_charging_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charging current', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging current', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_current', + 'unique_id': 'AA:BB_charging_current', + 'unit_of_measurement': , + }) +# --- +# name: test_number_state[number.garage_charging_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Garage Charging current', + : 32, + : 6, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.garage_charging_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16', + }) +# --- diff --git a/tests/components/besen/test_number.py b/tests/components/besen/test_number.py new file mode 100644 index 0000000000000..5cea69121b09a --- /dev/null +++ b/tests/components/besen/test_number.py @@ -0,0 +1,207 @@ +"""Tests for the Besen number platform.""" + +from unittest.mock import AsyncMock, Mock + +from besen.const import FALLBACK_MAX_CHARGE_AMPS +from besen.exceptions import CommandFailed +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.besen.const import DOMAIN +from homeassistant.components.number import ( + ATTR_MAX, + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_component import async_update_entity + +from . import publish_besen_state +from .conftest import charger_state, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "number.garage_charging_current" + + +async def test_number_state( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, +) -> None: + """Test number entity state and registry data.""" + + await setup_integration(hass, mock_config_entry, [Platform.NUMBER]) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + mock_besen_client.async_start.assert_awaited_once() + + +async def test_number_updates_from_client( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, +) -> None: + """Test number state updates from client push data.""" + + await setup_integration(hass, mock_config_entry, [Platform.NUMBER]) + + publish_besen_state(mock_besen_client, charger_state(charge_amps=20)) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == "20" + + +async def test_number_updates_on_refresh( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, +) -> None: + """Test number state updates when the coordinator refreshes.""" + + await setup_integration(hass, mock_config_entry, [Platform.NUMBER]) + + mock_besen_client.state = charger_state(charge_amps=20) + await async_update_entity(hass, ENTITY_ID) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == "20" + + +@pytest.mark.parametrize( + ("available", "authenticated"), + [ + (False, True), + (True, False), + ], +) +async def test_number_unavailable_from_client_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, + available: bool, + authenticated: bool, +) -> None: + """Test number availability follows client availability and authentication.""" + + await setup_integration(hass, mock_config_entry, [Platform.NUMBER]) + + publish_besen_state( + mock_besen_client, + charger_state(available=available, authenticated=authenticated), + ) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_number_unknown_without_reported_current( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, +) -> None: + """Test the number is unknown before the charger reports its current.""" + + mock_besen_client.state = charger_state(charge_amps=None) + + await setup_integration(hass, mock_config_entry, [Platform.NUMBER]) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("output_max_amps", "expected_max"), + [ + (16, 16), + (None, FALLBACK_MAX_CHARGE_AMPS), + ], +) +async def test_number_maximum( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, + output_max_amps: int | None, + expected_max: int, +) -> None: + """Test the maximum uses charger information with a safe fallback.""" + + mock_besen_client.state = charger_state( + charge_amps=16, + output_max_amps=output_max_amps, + ) + + await setup_integration(hass, mock_config_entry, [Platform.NUMBER]) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes[ATTR_MAX] == expected_max + + +async def test_number_set_value( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, +) -> None: + """Test setting the charging current calls the client and updates state.""" + + await setup_integration(hass, mock_config_entry, [Platform.NUMBER]) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_VALUE: 20}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_besen_client.async_set_charge_amps.assert_awaited_once_with(20) + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == "20" + + +async def test_number_command_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, +) -> None: + """Test command failures are translated to Home Assistant errors.""" + + mock_besen_client.async_set_charge_amps = AsyncMock( + side_effect=CommandFailed("failed") + ) + + await setup_integration(hass, mock_config_entry, [Platform.NUMBER]) + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_VALUE: 20}, + blocking=True, + ) + + assert err.value.translation_domain == DOMAIN + assert err.value.translation_key == "command_failed" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == "16" From 2d8957b5ce7c7a68c8fd96a7802a831407a9e5ee Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:07:59 +0200 Subject: [PATCH 15/18] Add parent device name part to generated entity IDs (#179996) --- homeassistant/helpers/entity_registry.py | 24 +- .../guntamatic/snapshots/test_sensor.ambr | 488 +++++++++--------- tests/components/guntamatic/test_sensor.py | 2 +- .../kitchen_sink/snapshots/test_sensor.ambr | 8 +- .../kitchen_sink/snapshots/test_switch.ambr | 8 +- tests/components/kitchen_sink/test_sensor.py | 5 +- tests/components/kitchen_sink/test_switch.py | 5 +- tests/helpers/test_entity_platform.py | 4 +- tests/helpers/test_entity_registry.py | 61 +++ 9 files changed, 347 insertions(+), 258 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index a2a2ce1ca2958..111fb3c4a75f7 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -150,6 +150,7 @@ class EntityNamePart(StrEnum): DEVICE = "device" ENTITY = "entity" FLOOR = "floor" + PARENT_DEVICE = "parent_device" @dataclass(frozen=True, kw_only=True, slots=True) @@ -521,11 +522,26 @@ def _async_get_full_entity_name( elif not use_legacy_naming or name is None: device_name: str | None = None + parent_device_name: str | None = None if device_id is not None: device_registry = dr.async_get(hass) if (device := device_registry.async_get(device_id)) is not None: device_name = device.name_by_user or device.name + if ( + EntityNamePart.PARENT_DEVICE in parts + and isinstance(device, dr.ChildDeviceEntry) + and ( + parent_device := device_registry.async_get( + device.parent_device_id, include_child_devices=False + ) + ) + is not None + ): + parent_device_name = ( + parent_device.name_by_user or parent_device.name + ) + if area_id is None: area_id = dr.async_get_effective_area_id(hass, device) @@ -569,6 +585,7 @@ def _async_get_full_entity_name( EntityNamePart.DEVICE: device_name, EntityNamePart.ENTITY: entity_name, EntityNamePart.FLOOR: floor_name, + EntityNamePart.PARENT_DEVICE: parent_device_name, } full_name = " ".join( part_name for part in parts if (part_name := part_names[part]) @@ -1370,7 +1387,12 @@ def _async_generate_entity_id( """ parts = self.settings.entity_id_parts if parts is None: - parts = (EntityNamePart.AREA, EntityNamePart.DEVICE, EntityNamePart.ENTITY) + parts = ( + EntityNamePart.AREA, + EntityNamePart.PARENT_DEVICE, + EntityNamePart.DEVICE, + EntityNamePart.ENTITY, + ) object_id = _async_get_full_entity_name( self.hass, area_id=area_id, diff --git a/tests/components/guntamatic/snapshots/test_sensor.ambr b/tests/components/guntamatic/snapshots/test_sensor.ambr index da894fa4dd7db..fe8a852bf5a3a 100644 --- a/tests/components/guntamatic/snapshots/test_sensor.ambr +++ b/tests/components/guntamatic/snapshots/test_sensor.ambr @@ -1,248 +1,4 @@ # serializer version: 1 -# name: test_all_entities[sensor.heating_circuit_1_flow_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.heating_circuit_1_flow_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Flow temperature', - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Flow temperature', - 'platform': 'guntamatic', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'circuit_temperature', - 'unique_id': '959103_circuit_1_temp', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.heating_circuit_1_flow_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Heating circuit 1 Flow temperature', - : , - : , - }), - 'context': , - 'entity_id': 'sensor.heating_circuit_1_flow_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '55.00', - }) -# --- -# name: test_all_entities[sensor.heating_circuit_1_program-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : list([ - 'off', - 'timer', - 'heat', - 'hibernate', - 'hibernate_to', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.heating_circuit_1_program', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Program', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Program', - 'platform': 'guntamatic', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'heating_circulation_program', - 'unique_id': '959103_heating_circulation_program_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.heating_circuit_1_program-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'enum', - : 'Heating circuit 1 Program', - : list([ - 'off', - 'timer', - 'heat', - 'hibernate', - 'hibernate_to', - ]), - }), - 'context': , - 'entity_id': 'sensor.heating_circuit_1_program', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'heat', - }) -# --- -# name: test_all_entities[sensor.heating_circuit_1_pump-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : list([ - 'auto', - 'off', - 'nonstop', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.heating_circuit_1_pump', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Pump', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Pump', - 'platform': 'guntamatic', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'heating_circulation_pump', - 'unique_id': '959103_heating_circulation_pump_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.heating_circuit_1_pump-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'enum', - : 'Heating circuit 1 Pump', - : list([ - 'auto', - 'off', - 'nonstop', - ]), - }), - 'context': , - 'entity_id': 'sensor.heating_circuit_1_pump', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'auto', - }) -# --- -# name: test_all_entities[sensor.heating_circuit_1_room_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.heating_circuit_1_room_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Room temperature', - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Room temperature', - 'platform': 'guntamatic', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'room_temperature', - 'unique_id': '959103_room_1_temperature', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.heating_circuit_1_room_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Heating circuit 1 Room temperature', - : , - : , - }), - 'context': , - 'entity_id': 'sensor.heating_circuit_1_room_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '21.50', - }) -# --- # name: test_all_entities[sensor.mock_title_auxiliary_pump_1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1283,6 +1039,250 @@ 'state': '12', }) # --- +# name: test_all_entities[sensor.mock_title_heating_circuit_1_flow_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_heating_circuit_1_flow_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Flow temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Flow temperature', + 'platform': 'guntamatic', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'circuit_temperature', + 'unique_id': '959103_circuit_1_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.mock_title_heating_circuit_1_flow_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Heating circuit 1 Flow temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.mock_title_heating_circuit_1_flow_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '55.00', + }) +# --- +# name: test_all_entities[sensor.mock_title_heating_circuit_1_program-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'timer', + 'heat', + 'hibernate', + 'hibernate_to', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_heating_circuit_1_program', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Program', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program', + 'platform': 'guntamatic', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating_circulation_program', + 'unique_id': '959103_heating_circulation_program_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.mock_title_heating_circuit_1_program-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Heating circuit 1 Program', + : list([ + 'off', + 'timer', + 'heat', + 'hibernate', + 'hibernate_to', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_title_heating_circuit_1_program', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_all_entities[sensor.mock_title_heating_circuit_1_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'auto', + 'off', + 'nonstop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_heating_circuit_1_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Pump', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pump', + 'platform': 'guntamatic', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating_circulation_pump', + 'unique_id': '959103_heating_circulation_pump_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.mock_title_heating_circuit_1_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Heating circuit 1 Pump', + : list([ + 'auto', + 'off', + 'nonstop', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_title_heating_circuit_1_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_all_entities[sensor.mock_title_heating_circuit_1_room_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_heating_circuit_1_room_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Room temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Room temperature', + 'platform': 'guntamatic', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'room_temperature', + 'unique_id': '959103_room_1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.mock_title_heating_circuit_1_room_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Heating circuit 1 Room temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.mock_title_heating_circuit_1_room_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.50', + }) +# --- # name: test_all_entities[sensor.mock_title_interruption_1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/guntamatic/test_sensor.py b/tests/components/guntamatic/test_sensor.py index 0fe7458a60e12..e144b12af07b1 100644 --- a/tests/components/guntamatic/test_sensor.py +++ b/tests/components/guntamatic/test_sensor.py @@ -171,7 +171,7 @@ async def test_enum_sensor_unmapped_value( await setup_integration(hass, mock_config_entry) await hass.async_block_till_done() - state = hass.states.get("sensor.heating_circuit_1_pump") + state = hass.states.get("sensor.mock_title_heating_circuit_1_pump") assert state is not None assert state.state == STATE_UNKNOWN diff --git a/tests/components/kitchen_sink/snapshots/test_sensor.ambr b/tests/components/kitchen_sink/snapshots/test_sensor.ambr index 47be1098dfa6c..79a793ae5df15 100644 --- a/tests/components/kitchen_sink/snapshots/test_sensor.ambr +++ b/tests/components/kitchen_sink/snapshots/test_sensor.ambr @@ -9,7 +9,7 @@ : , }), 'context': , - 'entity_id': 'sensor.outlet_1_power', + 'entity_id': 'sensor.power_strip_with_2_sockets_outlet_1_power', 'last_changed': , 'last_reported': , 'last_updated': , @@ -23,7 +23,7 @@ : , }), 'context': , - 'entity_id': 'sensor.outlet_2_power', + 'entity_id': 'sensor.power_strip_with_2_sockets_outlet_2_power', 'last_changed': , 'last_reported': , 'last_updated': , @@ -93,7 +93,7 @@ : , }), 'context': , - 'entity_id': 'sensor.outlet_1_power', + 'entity_id': 'sensor.power_strip_with_2_sockets_outlet_1_power', 'last_changed': , 'last_reported': , 'last_updated': , @@ -107,7 +107,7 @@ : , }), 'context': , - 'entity_id': 'sensor.outlet_2_power', + 'entity_id': 'sensor.power_strip_with_2_sockets_outlet_2_power', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/kitchen_sink/snapshots/test_switch.ambr b/tests/components/kitchen_sink/snapshots/test_switch.ambr index beeb487a295c8..7093c991ba8a7 100644 --- a/tests/components/kitchen_sink/snapshots/test_switch.ambr +++ b/tests/components/kitchen_sink/snapshots/test_switch.ambr @@ -5,7 +5,7 @@ : 'Outlet 1', }), 'context': , - 'entity_id': 'switch.outlet_1', + 'entity_id': 'switch.power_strip_with_2_sockets_outlet_1', 'last_changed': , 'last_reported': , 'last_updated': , @@ -26,7 +26,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.outlet_1', + 'entity_id': 'switch.power_strip_with_2_sockets_outlet_1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -105,7 +105,7 @@ : 'Outlet 2', }), 'context': , - 'entity_id': 'switch.outlet_2', + 'entity_id': 'switch.power_strip_with_2_sockets_outlet_2', 'last_changed': , 'last_reported': , 'last_updated': , @@ -126,7 +126,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.outlet_2', + 'entity_id': 'switch.power_strip_with_2_sockets_outlet_2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, diff --git a/tests/components/kitchen_sink/test_sensor.py b/tests/components/kitchen_sink/test_sensor.py index f1c11f6a16485..2b691a5ac0cd9 100644 --- a/tests/components/kitchen_sink/test_sensor.py +++ b/tests/components/kitchen_sink/test_sensor.py @@ -46,7 +46,10 @@ async def test_outlet_power_sensors_on_child_devices( entity_registry: er.EntityRegistry, ) -> None: """Test the outlet power sensors are placed on child devices of the power strip.""" - for entity_id in ("sensor.outlet_1_power", "sensor.outlet_2_power"): + for entity_id in ( + "sensor.power_strip_with_2_sockets_outlet_1_power", + "sensor.power_strip_with_2_sockets_outlet_2_power", + ): entity_entry = entity_registry.async_get(entity_id) assert entity_entry is not None child_device = device_registry.async_get(entity_entry.device_id) diff --git a/tests/components/kitchen_sink/test_switch.py b/tests/components/kitchen_sink/test_switch.py index a00247ca169cd..cab56e398eb66 100644 --- a/tests/components/kitchen_sink/test_switch.py +++ b/tests/components/kitchen_sink/test_switch.py @@ -17,7 +17,10 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -SWITCH_ENTITY_IDS = ["switch.outlet_1", "switch.outlet_2"] +SWITCH_ENTITY_IDS = [ + "switch.power_strip_with_2_sockets_outlet_1", + "switch.power_strip_with_2_sockets_outlet_2", +] @pytest.fixture diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index d6f8326de14af..cb2a02111efa8 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -3077,8 +3077,8 @@ async def async_setup_entry( entity_id = entity_registry.async_get_entity_id( "test_domain", config_entry.domain, "power" ) - # The child device's name is the device part of the generated entity id - assert entity_id == "test_domain.outlet_1_power" + # The generated entity id includes the parent and child device names + assert entity_id == "test_domain.power_strip_outlet_1_power" entry = entity_registry.async_get(entity_id) assert entry is not None assert entry.device_id == child_device.id diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 39e13a77340f6..0323d35c755e8 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -1176,6 +1176,67 @@ def test_generate_entity_id_parts_entity_area( assert new_entity_id == "sensor.second_floor_garage_lamp_temperature" +def test_generate_entity_id_parent_device_part( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the parent device part for entities on child devices.""" + config_entry = MockConfigEntry(domain="sensor") + config_entry.add_to_hass(hass) + + parent_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "strip")}, + name="Power strip", + ) + child_device = device_registry.async_get_or_create_child( + config_entry_id=config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + parent_device_id=parent_device.id, + name="Outlet 1", + ) + + # The default parts include the parent device name + entry = entity_registry.async_get_or_create( + "sensor", + "test", + "1234", + config_entry=config_entry, + device_id=child_device.id, + has_entity_name=True, + object_id_base="Power", + original_name="Power", + ) + assert entry.entity_id == "sensor.power_strip_outlet_1_power" + + # A parent device name set by the user is used + device_registry.async_update_device(parent_device.id, name_by_user="Kitchen strip") + assert ( + entity_registry.async_regenerate_entity_id(entry) + == "sensor.kitchen_strip_outlet_1_power" + ) + + # An entity on a main device is not affected by the parent device part + main_entry = entity_registry.async_get_or_create( + "sensor", + "test", + "5678", + config_entry=config_entry, + device_id=parent_device.id, + has_entity_name=True, + object_id_base="Power", + original_name="Power", + ) + assert main_entry.entity_id == "sensor.kitchen_strip_power" + + # Parts without the parent device part exclude the parent device name + entity_registry.async_update_settings( + entity_id_parts=[er.EntityNamePart.DEVICE, er.EntityNamePart.ENTITY] + ) + assert entity_registry.async_regenerate_entity_id(entry) == "sensor.outlet_1_power" + + def test_regenerate_entity_id_after_settings_change( hass: HomeAssistant, area_registry: ar.AreaRegistry, From bb2e7f1629ebc29b4fcad5f57ad461da78e58ad9 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 10 Sep 2026 15:42:42 +0200 Subject: [PATCH 16/18] Fix Yoto card slot sensor name (#181844) --- homeassistant/components/yoto/strings.json | 4 ++-- tests/components/yoto/snapshots/test_sensor.ambr | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/yoto/strings.json b/homeassistant/components/yoto/strings.json index 1ab512a8b3532..0bf3e5c0c6ab2 100644 --- a/homeassistant/components/yoto/strings.json +++ b/homeassistant/components/yoto/strings.json @@ -80,9 +80,9 @@ }, "sensor": { "card_insertion_state": { - "name": "Card slot", + "name": "Content source", "state": { - "none": "Empty", + "none": "None", "physical": "Physical card", "remote": "Remote", "streaming": "Streaming" diff --git a/tests/components/yoto/snapshots/test_sensor.ambr b/tests/components/yoto/snapshots/test_sensor.ambr index 523f1a41e433c..f9ca7f459fe63 100644 --- a/tests/components/yoto/snapshots/test_sensor.ambr +++ b/tests/components/yoto/snapshots/test_sensor.ambr @@ -54,7 +54,7 @@ 'state': '75', }) # --- -# name: test_all_entities[sensor.nursery_yoto_card_slot-entry] +# name: test_all_entities[sensor.nursery_yoto_content_source-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -75,7 +75,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.nursery_yoto_card_slot', + 'entity_id': 'sensor.nursery_yoto_content_source', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -83,12 +83,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Card slot', + 'object_id_base': 'Content source', 'options': dict({ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Card slot', + 'original_name': 'Content source', 'platform': 'yoto', 'previous_unique_id': None, 'suggested_object_id': None, @@ -98,11 +98,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.nursery_yoto_card_slot-state] +# name: test_all_entities[sensor.nursery_yoto_content_source-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'enum', - : 'Nursery Yoto Card slot', + : 'Nursery Yoto Content source', : list([ 'none', 'physical', @@ -111,7 +111,7 @@ ]), }), 'context': , - 'entity_id': 'sensor.nursery_yoto_card_slot', + 'entity_id': 'sensor.nursery_yoto_content_source', 'last_changed': , 'last_reported': , 'last_updated': , From 76bf1328f90d2dee2a391eb4fb5d7f2488db8148 Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Thu, 10 Sep 2026 15:48:35 +0200 Subject: [PATCH 17/18] Avoid duplicates when using HassShoppingListAddItem to modify shopping list (#179726) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/shopping_list/intent.py | 32 +++++--- tests/components/shopping_list/test_init.py | 18 +++++ tests/components/shopping_list/test_intent.py | 74 +++++++++++++++---- 3 files changed, 101 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/shopping_list/intent.py b/homeassistant/components/shopping_list/intent.py index 0e796d4b53bc5..4ea080154404f 100644 --- a/homeassistant/components/shopping_list/intent.py +++ b/homeassistant/components/shopping_list/intent.py @@ -1,12 +1,12 @@ """Intents for the Shopping List integration.""" -from typing import override +from typing import cast, override from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, intent from .common import NoMatchingShoppingListItem, _get_shopping_data -from .const import DOMAIN, EVENT_SHOPPING_LIST_UPDATED +from .const import DOMAIN INTENT_ADD_ITEM = "HassShoppingListAddItem" INTENT_COMPLETE_ITEM = "HassShoppingListCompleteItem" @@ -32,12 +32,28 @@ class AddItemIntent(intent.IntentHandler): async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the intent.""" slots = self.async_validate_slots(intent_obj.slots) - item = slots["item"]["value"].strip() - await _get_shopping_data(intent_obj.hass).async_add(item) + item_name = slots["item"]["value"].strip() + shopping_data = _get_shopping_data(intent_obj.hass) + completed_match = None + normalized_name = item_name.casefold() + for item in shopping_data.items: + name = item["name"] + if not isinstance(name, str) or name.casefold() != normalized_name: + continue + if not item["complete"]: + return intent_obj.create_response() + if completed_match is None: + completed_match = item + + if completed_match is None: + await shopping_data.async_add(item_name) + else: + await shopping_data.async_update( + cast(str, completed_match["id"]), + {"name": cast(str, completed_match["name"]), "complete": False}, + ) - response = intent_obj.create_response() - intent_obj.hass.bus.async_fire(EVENT_SHOPPING_LIST_UPDATED) - return response + return intent_obj.create_response() class CompleteItemIntent(intent.IntentHandler): @@ -61,8 +77,6 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse except NoMatchingShoppingListItem: complete_items = [] - intent_obj.hass.bus.async_fire(EVENT_SHOPPING_LIST_UPDATED) - response = intent_obj.create_response() response.async_set_speech_slots({"completed_items": complete_items}) diff --git a/tests/components/shopping_list/test_init.py b/tests/components/shopping_list/test_init.py index 7bc18746894e4..126570695d060 100644 --- a/tests/components/shopping_list/test_init.py +++ b/tests/components/shopping_list/test_init.py @@ -51,12 +51,15 @@ async def test_add_item( hass: HomeAssistant, sl_setup: None, snapshot: SnapshotAssertion ) -> None: """Test adding an item intent.""" + events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) response = await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": " beer "}} ) assert len(_get_shopping_data(hass).items) == 1 assert _get_shopping_data(hass).items[0]["name"] == "beer" # name was trimmed + assert len(events) == 1 + assert events[0].data["action"] == "add" # Response text is now handled by default conversation agent assert response.response_type is intent.IntentResponseType.ACTION_DONE @@ -797,6 +800,21 @@ async def test_add_item_service( assert_shopping_list_data(hass, snapshot) +async def test_add_item_service_allows_duplicates( + hass: HomeAssistant, sl_setup: None +) -> None: + """Test adding duplicate items with the shopping list service.""" + for _ in range(2): + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_ITEM, + {ATTR_NAME: "beer"}, + blocking=True, + ) + + assert len(_get_shopping_data(hass).items) == 2 + + async def test_remove_item_service( hass: HomeAssistant, sl_setup: None, snapshot: SnapshotAssertion ) -> None: diff --git a/tests/components/shopping_list/test_intent.py b/tests/components/shopping_list/test_intent.py index 918ae0340ff64..ff0ce2d0cd420 100644 --- a/tests/components/shopping_list/test_intent.py +++ b/tests/components/shopping_list/test_intent.py @@ -1,24 +1,70 @@ """Test Shopping List intents.""" from homeassistant.components.shopping_list.common import _get_shopping_data +from homeassistant.components.shopping_list.const import EVENT_SHOPPING_LIST_UPDATED from homeassistant.core import HomeAssistant from homeassistant.helpers import intent +from tests.common import async_capture_events -async def test_complete_item_intent(hass: HomeAssistant, sl_setup) -> None: - """Test complete item.""" - await intent.async_handle( - hass, "test", "HassShoppingListAddItem", {"item": {"value": "soda"}} - ) - await intent.async_handle( + +async def test_add_item_intent_reactivates_first_completed_match( + hass: HomeAssistant, sl_setup: None +) -> None: + """Test reactivating the first completed matching item.""" + shopping_data = _get_shopping_data(hass) + first_item = await shopping_data.async_add("Beer", complete=True) + second_item = await shopping_data.async_add("BEER", complete=True) + first_item_id = first_item["id"] + second_item_id = second_item["id"] + events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) + + response = await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}} ) - await intent.async_handle( + + assert response.response_type is intent.IntentResponseType.ACTION_DONE + assert len(shopping_data.items) == 2 + assert shopping_data.items[0]["id"] == first_item_id + assert shopping_data.items[0]["name"] == "Beer" + assert shopping_data.items[0]["complete"] is False + assert shopping_data.items[1]["id"] == second_item_id + assert shopping_data.items[1]["complete"] is True + assert len(events) == 1 + assert events[0].data["action"] == "update" + + +async def test_add_item_intent_keeps_existing_active_match( + hass: HomeAssistant, sl_setup: None +) -> None: + """Test keeping an existing active matching item.""" + shopping_data = _get_shopping_data(hass) + completed_item = await shopping_data.async_add("Beer", complete=True) + active_item = await shopping_data.async_add("BEER") + completed_item_id = completed_item["id"] + active_item_id = active_item["id"] + events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) + + response = await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}} ) - await intent.async_handle( - hass, "test", "HassShoppingListAddItem", {"item": {"value": "wine"}} - ) + + assert response.response_type is intent.IntentResponseType.ACTION_DONE + assert len(shopping_data.items) == 2 + assert shopping_data.items[0]["id"] == completed_item_id + assert shopping_data.items[0]["complete"] is True + assert shopping_data.items[1]["id"] == active_item_id + assert shopping_data.items[1]["complete"] is False + assert not events + + +async def test_complete_item_intent(hass: HomeAssistant, sl_setup: None) -> None: + """Test complete item.""" + shopping_data = _get_shopping_data(hass) + await shopping_data.async_add("soda") + await shopping_data.async_add("beer") + await shopping_data.async_add("beer") + await shopping_data.async_add("wine") response = await intent.async_handle( hass, "test", "HassShoppingListCompleteItem", {"item": {"value": "beer"}} @@ -28,8 +74,8 @@ async def test_complete_item_intent(hass: HomeAssistant, sl_setup) -> None: completed_items = response.speech_slots.get("completed_items") assert len(completed_items) == 2 assert completed_items[0]["name"] == "beer" - assert _get_shopping_data(hass).items[1]["complete"] - assert _get_shopping_data(hass).items[2]["complete"] + assert shopping_data.items[1]["complete"] + assert shopping_data.items[2]["complete"] # Complete again response = await intent.async_handle( @@ -38,8 +84,8 @@ async def test_complete_item_intent(hass: HomeAssistant, sl_setup) -> None: assert response.response_type is intent.IntentResponseType.ACTION_DONE assert response.speech_slots.get("completed_items") == [] - assert _get_shopping_data(hass).items[1]["complete"] - assert _get_shopping_data(hass).items[2]["complete"] + assert shopping_data.items[1]["complete"] + assert shopping_data.items[2]["complete"] async def test_complete_item_intent_not_found(hass: HomeAssistant, sl_setup) -> None: From 562f27d7cb8005028706f1dc0971cdb1a111eab7 Mon Sep 17 00:00:00 2001 From: rrooggiieerr Date: Thu, 10 Sep 2026 16:10:46 +0200 Subject: [PATCH 18/18] Add Button platform to my-PV integration (#181835) --- homeassistant/components/my_pv/__init__.py | 1 + homeassistant/components/my_pv/button.py | 58 +++++++ homeassistant/components/my_pv/coordinator.py | 9 + homeassistant/components/my_pv/entity.py | 16 +- tests/components/my_pv/conftest.py | 12 ++ .../my_pv/snapshots/test_button.ambr | 52 ++++++ tests/components/my_pv/test_button.py | 155 ++++++++++++++++++ 7 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/my_pv/button.py create mode 100644 tests/components/my_pv/snapshots/test_button.ambr create mode 100644 tests/components/my_pv/test_button.py diff --git a/homeassistant/components/my_pv/__init__.py b/homeassistant/components/my_pv/__init__.py index 46c705e601d97..0f8722bb56c9f 100644 --- a/homeassistant/components/my_pv/__init__.py +++ b/homeassistant/components/my_pv/__init__.py @@ -11,6 +11,7 @@ from .coordinator import MyPVConfigEntry, MyPVCoordinator PLATFORMS: list[Platform] = [ + Platform.BUTTON, Platform.WATER_HEATER, ] diff --git a/homeassistant/components/my_pv/button.py b/homeassistant/components/my_pv/button.py new file mode 100644 index 0000000000000..1583412e450fd --- /dev/null +++ b/homeassistant/components/my_pv/button.py @@ -0,0 +1,58 @@ +# pylint: disable=duplicate-code +"""Creates Button entities for the my-PV Home Assistant integration.""" + +from typing import Any, override + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import MyPVConfigEntry +from .const import DOMAIN +from .entity import MyPVBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MyPVConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the my-PV button.""" + coordinator = config_entry.runtime_data + entities = [] + + config = coordinator.device.get_command_configuration("reboot_device") + if config and config.get("type") in ["any", "fixed"]: + entity_description = ButtonEntityDescription( + key="reboot_device", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.DIAGNOSTIC, + ) + entities.append( + MyPVCommandButton( + coordinator, + entity_description, + coordinator.device.serial_number, + ) + ) + + async_add_entities(entities) + + +class MyPVCommandButton(MyPVBaseEntity, ButtonEntity): + """Base my-PV Button.""" + + @override + async def async_press(self, **kwargs: Any) -> None: + """Handle the button press.""" + + if not await self.coordinator.send_command(self.entity_description.key): + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="unknown_error" + ) diff --git a/homeassistant/components/my_pv/coordinator.py b/homeassistant/components/my_pv/coordinator.py index 2c1413edd4241..304043b53a728 100644 --- a/homeassistant/components/my_pv/coordinator.py +++ b/homeassistant/components/my_pv/coordinator.py @@ -144,6 +144,15 @@ async def set_target_temperature(self, temperature: float) -> bool: self.async_update_listeners() return result + @_my_pv_connection + async def send_command( + self, key: str, value: bool | float | str | None = None + ) -> bool: + """Send command.""" + result = await self.device.send_command(key, value) + self.async_update_listeners() + return result + @_my_pv_connection async def turn_on(self) -> bool: """Turn on the device.""" diff --git a/homeassistant/components/my_pv/entity.py b/homeassistant/components/my_pv/entity.py index 39a1784d00a20..247a2a08babfd 100644 --- a/homeassistant/components/my_pv/entity.py +++ b/homeassistant/components/my_pv/entity.py @@ -8,8 +8,8 @@ from .coordinator import MyPVCoordinator -class MyPVDataEntity(CoordinatorEntity[MyPVCoordinator]): - """The my-PV data entity.""" +class MyPVBaseEntity(CoordinatorEntity[MyPVCoordinator]): + """The my-PV base entity.""" _attr_has_entity_name = True @@ -35,6 +35,18 @@ def available(self) -> bool: super().available and self.coordinator.device.connected and self.coordinator.device.is_on is not None + ) + + +class MyPVDataEntity(MyPVBaseEntity): + """The my-PV data entity.""" + + @property + @override + def available(self) -> bool: + """Return if entity is available.""" + return ( + super().available and self.coordinator.device.get_data_value(self.entity_description.key) is not None ) diff --git a/tests/components/my_pv/conftest.py b/tests/components/my_pv/conftest.py index 7826330dd482b..e26001a92c39c 100644 --- a/tests/components/my_pv/conftest.py +++ b/tests/components/my_pv/conftest.py @@ -16,11 +16,17 @@ "ww1target": {"step": 0.1, "unit": "°C", "min": 5.0, "max": 95.0} } +COMMAND_CONFIGURATION = {"reboot_device": {"type": "any"}} + def _setup_configuration_lookup(key): return SETUP_CONFIGURATION.get(key) +def _command_configuration_lookup(key): + return COMMAND_CONFIGURATION.get(key) + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the my-PV mocked config entry for local devices.""" @@ -74,5 +80,11 @@ def mock_my_pv_client() -> Generator[AsyncMock]: client.current_temperature = 54.3 client.target_temperature = 62.1 client.get_setup_configuration = Mock(side_effect=_setup_configuration_lookup) + client.get_command_configuration = Mock( + side_effect=_command_configuration_lookup + ) + client.connected = True + client.is_on = True + client.send_command = AsyncMock(return_value=True) yield client diff --git a/tests/components/my_pv/snapshots/test_button.ambr b/tests/components/my_pv/snapshots/test_button.ambr new file mode 100644 index 0000000000000..a3512d61fc19b --- /dev/null +++ b/tests/components/my_pv/snapshots/test_button.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_button[button.my_pv_ac_elwa_2_restart-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': 'button', + 'entity_category': , + 'entity_id': 'button.my_pv_ac_elwa_2_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Restart', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart', + 'platform': 'my_pv', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1601500000000000-reboot_device', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.my_pv_ac_elwa_2_restart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'restart', + : 'my-PV AC ELWA 2 Restart', + }), + 'context': , + 'entity_id': 'button.my_pv_ac_elwa_2_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/my_pv/test_button.py b/tests/components/my_pv/test_button.py new file mode 100644 index 0000000000000..1c316b580d436 --- /dev/null +++ b/tests/components/my_pv/test_button.py @@ -0,0 +1,155 @@ +"""Test the my-PV button platform.""" + +from unittest.mock import AsyncMock, patch + +from my_pv.exceptions import MyPVAuthenticationError, MyPVConnectionError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("mock_my_pv_client") +async def test_button( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test successful setup of a button platform.""" + + with patch("homeassistant.components.my_pv.PLATFORMS", [Platform.BUTTON]): + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_button_unavailable_not_connected( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_my_pv_client: AsyncMock, +) -> None: + """Test if a button is unavailable when not connected.""" + + with patch("homeassistant.components.my_pv.PLATFORMS", [Platform.BUTTON]): + mock_config_entry.add_to_hass(hass) + + mock_my_pv_client.connected = False + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("button.my_pv_ac_elwa_2_restart") + assert state.state == STATE_UNAVAILABLE + + +async def test_button_press( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_my_pv_client: AsyncMock, +) -> None: + """Test successful press of a button.""" + + with patch("homeassistant.components.my_pv.PLATFORMS", [Platform.BUTTON]): + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {"entity_id": "button.my_pv_ac_elwa_2_restart"}, + blocking=True, + ) + mock_my_pv_client.send_command.assert_awaited_once_with("reboot_device", None) + + +async def test_button_press_send_command_returns_false( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_my_pv_client: AsyncMock, +) -> None: + """Test for HomeAssistantError when send_command returns False.""" + + with patch("homeassistant.components.my_pv.PLATFORMS", [Platform.BUTTON]): + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_my_pv_client.send_command.return_value = False + with ( + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {"entity_id": "button.my_pv_ac_elwa_2_restart"}, + blocking=True, + ) + mock_my_pv_client.send_command.assert_awaited_once_with("reboot_device", None) + + mock_my_pv_client.send_command.reset_mock() + mock_my_pv_client.send_command.return_value = True + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {"entity_id": "button.my_pv_ac_elwa_2_restart"}, + blocking=True, + ) + mock_my_pv_client.send_command.assert_awaited_once_with("reboot_device", None) + + +@pytest.mark.parametrize( + ("error", "expected_ha_error"), + [ + (MyPVConnectionError(), HomeAssistantError), + (MyPVAuthenticationError(), ConfigEntryAuthFailed), + ], +) +async def test_button_press_send_command_throws_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_my_pv_client: AsyncMock, + error: MyPVConnectionError | MyPVAuthenticationError, + expected_ha_error: type[HomeAssistantError], +) -> None: + """Test for HomeAssistantError when send_command throws error.""" + + with patch("homeassistant.components.my_pv.PLATFORMS", [Platform.BUTTON]): + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_my_pv_client.send_command.side_effect = error + with ( + pytest.raises(expected_ha_error), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {"entity_id": "button.my_pv_ac_elwa_2_restart"}, + blocking=True, + ) + mock_my_pv_client.send_command.assert_awaited_once_with("reboot_device", None) + + mock_my_pv_client.send_command.reset_mock() + mock_my_pv_client.send_command.side_effect = None + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {"entity_id": "button.my_pv_ac_elwa_2_restart"}, + blocking=True, + ) + mock_my_pv_client.send_command.assert_awaited_once_with("reboot_device", None)