diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 92ede6a5a3afc8..10b19d2c42729d 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -7,7 +7,7 @@ from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any, cast +from typing import Any from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( @@ -247,14 +247,15 @@ async def _event_listener(self) -> None: # noqa: C901 value=event.value, ) else: + event_value = event.value if event_key in ( EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, - ): + ) and isinstance(event_value, str): await self.update_options( event_message_ha_id, event_key, - ProgramKey(cast(str, event.value)), + ProgramKey(event_value), ) events[event_key] = event self._call_event_listener(event_message) diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index a3368ce550ccbc..4c3e9702cd0bd7 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -14,7 +14,6 @@ TooManyRequestsError, ) -from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo @@ -62,10 +61,8 @@ def update_native_value(self) -> None: def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" self.update_native_value() - available = self._attr_available = self.appliance.info.connected self.async_write_ha_state() - state = STATE_UNAVAILABLE if not available else self.state - _LOGGER.debug("Updated %s, new state: %s", self.entity_id, state) + _LOGGER.debug("Updated %s", self) @property def bsh_key(self) -> str: @@ -80,7 +77,7 @@ def available(self) -> bool: as event updates should take precedence over the coordinator refresh. """ - return self._attr_available + return self.appliance.info.connected and self._attr_available class HomeConnectOptionEntity(HomeConnectEntity): diff --git a/homeassistant/components/mvglive/manifest.json b/homeassistant/components/mvglive/manifest.json index 8058c602dc4da0..0229ef35b9c303 100644 --- a/homeassistant/components/mvglive/manifest.json +++ b/homeassistant/components/mvglive/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/mvglive", "iot_class": "cloud_polling", "loggers": ["MVG"], + "quality_scale": "legacy", "requirements": ["mvg==1.4.0"] } diff --git a/homeassistant/components/sfr_box/__init__.py b/homeassistant/components/sfr_box/__init__.py index f5efe82379f146..b9e97175ed51ca 100644 --- a/homeassistant/components/sfr_box/__init__.py +++ b/homeassistant/components/sfr_box/__init__.py @@ -81,4 +81,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SFRConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: SFRConfigEntry) -> bool: """Unload a config entry.""" + if entry.data.get(CONF_USERNAME) and entry.data.get(CONF_PASSWORD): + return await hass.config_entries.async_unload_platforms( + entry, PLATFORMS_WITH_AUTH + ) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 694c467b56623b..10655b69535174 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -63,6 +63,7 @@ get_blu_trv_device_info, get_device_entry_gen, get_device_uptime, + get_entity_translation_attributes, get_rpc_channel_name, get_shelly_air_lamp_life, get_virtual_component_unit, @@ -73,25 +74,6 @@ PARALLEL_UPDATES = 0 -def get_entity_translation_attributes( - channel_name: str | None, - translation_key: str | None, - device_class: str | None, - default_to_device_class_name: bool, -) -> tuple[dict[str, str] | None, str | None]: - """Translation attributes for entity with channel name.""" - if channel_name is None: - return None, None - - key = translation_key - if key is None and default_to_device_class_name: - key = device_class - - final_translation_key = f"{key}_with_channel_name" if key else None - - return {"channel_name": channel_name}, final_translation_key - - @dataclass(frozen=True, kw_only=True) class BlockSensorDescription(BlockEntityDescription, SensorEntityDescription): """Class to describe a BLOCK sensor.""" diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index e40e5d91f6dc18..24814dcea147fc 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -459,6 +459,23 @@ def get_rpc_entity_name( return channel_name +def get_entity_translation_attributes( + channel_name: str | None, + translation_key: str | None, + device_class: str | None, + default_to_device_class_name: bool, +) -> tuple[dict[str, str] | None, str | None]: + """Translation attributes for entity with channel name.""" + if channel_name is None: + return None, None + + key = translation_key + if key is None and default_to_device_class_name: + key = device_class + + return {"channel_name": channel_name}, f"{key}_with_channel_name" if key else None + + def get_device_entry_gen(entry: ConfigEntry) -> int: """Return the device generation from config entry.""" return entry.data.get(CONF_GEN, 1) # type: ignore[no-any-return] diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 485468bda6a52f..53d52c790cb11f 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -29,7 +29,7 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType, WorkMode from .entity import TuyaEntity -from .models import IntegerTypeData, find_dpcode +from .models import DPCodeBooleanWrapper, IntegerTypeData, find_dpcode from .util import get_dpcode, get_dptype, remap_value @@ -428,9 +428,15 @@ def async_discover_device(device_ids: list[str]): device = manager.device_map[device_id] if descriptions := LIGHTS.get(device.category): entities.extend( - TuyaLightEntity(device, manager, description) + TuyaLightEntity( + device, manager, description, switch_wrapper=switch_wrapper + ) for description in descriptions - if description.key in device.status + if ( + switch_wrapper := DPCodeBooleanWrapper.find_dpcode( + device, description.key, prefer_function=True + ) + ) ) async_add_entities(entities) @@ -464,11 +470,15 @@ def __init__( device: CustomerDevice, device_manager: Manager, description: TuyaLightEntityDescription, + *, + switch_wrapper: DPCodeBooleanWrapper, ) -> None: """Init TuyaHaLight.""" super().__init__(device, device_manager) self.entity_description = description self._attr_unique_id = f"{super().unique_id}{description.key}" + self._switch_wrapper = switch_wrapper + color_modes: set[ColorMode] = {ColorMode.ONOFF} # Determine DPCodes @@ -546,13 +556,15 @@ def __init__( self._fixed_color_mode = next(iter(self._attr_supported_color_modes)) @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return true if light is on.""" - return self.device.status.get(self.entity_description.key, False) + return self._read_wrapper(self._switch_wrapper) def turn_on(self, **kwargs: Any) -> None: """Turn on or control the light.""" - commands = [{"code": self.entity_description.key, "value": True}] + commands = [ + self._switch_wrapper.get_update_command(self.device, True), + ] if self._color_mode_dpcode and ( ATTR_WHITE in kwargs or ATTR_COLOR_TEMP_KELVIN in kwargs @@ -673,9 +685,9 @@ def turn_on(self, **kwargs: Any) -> None: self._send_command(commands) - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" - self._send_command([{"code": self.entity_description.key, "value": False}]) + await self._async_send_dpcode_update(self._switch_wrapper, False) @property def brightness(self) -> int | None: diff --git a/homeassistant/components/velux/entity.py b/homeassistant/components/velux/entity.py index 27c3d57e37be73..2b1f379f4cc3d8 100644 --- a/homeassistant/components/velux/entity.py +++ b/homeassistant/components/velux/entity.py @@ -1,8 +1,9 @@ """Support for VELUX KLF 200 devices.""" +from collections.abc import Awaitable, Callable + from pyvlx import Node -from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -14,6 +15,7 @@ class VeluxEntity(Entity): _attr_should_poll = False _attr_has_entity_name = True + update_callback: Callable[["Node"], Awaitable[None]] | None = None def __init__(self, node: Node, config_entry_id: str) -> None: """Initialize the Velux device.""" @@ -24,6 +26,7 @@ def __init__(self, node: Node, config_entry_id: str) -> None: else f"{config_entry_id}_{node.node_id}" ) self._attr_unique_id = unique_id + self.unsubscribe = None self._attr_device_info = DeviceInfo( identifiers={ @@ -37,16 +40,18 @@ def __init__(self, node: Node, config_entry_id: str) -> None: via_device=(DOMAIN, f"gateway_{config_entry_id}"), ) - @callback - def async_register_callbacks(self): - """Register callbacks to update hass after device was changed.""" + async def after_update_callback(self, node) -> None: + """Call after device was updated.""" + self.async_write_ha_state() - async def after_update_callback(device): - """Call after device was updated.""" - self.async_write_ha_state() + async def async_added_to_hass(self) -> None: + """Register callback and store reference for cleanup.""" - self.node.register_device_updated_cb(after_update_callback) + self.update_callback = self.after_update_callback + self.node.register_device_updated_cb(self.update_callback) - async def async_added_to_hass(self) -> None: - """Store register state change callback.""" - self.async_register_callbacks() + async def async_will_remove_from_hass(self) -> None: + """Clean up registered callbacks.""" + if self.update_callback: + self.node.unregister_device_updated_cb(self.update_callback) + self.update_callback = None diff --git a/homeassistant/components/velux/quality_scale.yaml b/homeassistant/components/velux/quality_scale.yaml index f19c3487ba72e5..c83140ea724527 100644 --- a/homeassistant/components/velux/quality_scale.yaml +++ b/homeassistant/components/velux/quality_scale.yaml @@ -15,13 +15,9 @@ rules: docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done - entity-event-setup: - status: todo - comment: subscribe is ok, unsubscribe needs to be added + entity-event-setup: done entity-unique-id: done - has-entity-name: - status: todo - comment: scenes need fixing + has-entity-name: done runtime-data: done test-before-configure: done test-before-setup: diff --git a/homeassistant/components/velux/scene.py b/homeassistant/components/velux/scene.py index 8067dc51130f19..93a39752b322fb 100644 --- a/homeassistant/components/velux/scene.py +++ b/homeassistant/components/velux/scene.py @@ -4,11 +4,15 @@ from typing import Any +from pyvlx import Scene as PyVLXScene + from homeassistant.components.scene import Scene from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VeluxConfigEntry +from .const import DOMAIN PARALLEL_UPDATES = 1 @@ -20,22 +24,32 @@ async def async_setup_entry( ) -> None: """Set up the scenes for Velux platform.""" pyvlx = config_entry.runtime_data - - entities = [VeluxScene(scene) for scene in pyvlx.scenes] - async_add_entities(entities) + async_add_entities( + [VeluxScene(config_entry.entry_id, scene) for scene in pyvlx.scenes] + ) class VeluxScene(Scene): """Representation of a Velux scene.""" - def __init__(self, scene): + _attr_has_entity_name = True + + # Note: there's currently no code to update the scenes dynamically if changed in + # the gateway. They're only loaded on integration setup (they're probably not + # used heavily anyway since it's a pain to set them up in the gateway and so + # much easier to use HA scenes). + + def __init__(self, config_entry_id: str, scene: PyVLXScene) -> None: """Init velux scene.""" self.scene = scene - - @property - def name(self): - """Return the name of the scene.""" - return self.scene.name + # Renaming scenes in gateway keeps scene_id stable, we can use it as unique_id + self._attr_unique_id = f"{config_entry_id}_scene_{scene.scene_id}" + self._attr_name = scene.name + + # Associate scenes with the gateway device (where they are stored) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"gateway_{config_entry_id}")}, + ) async def async_activate(self, **kwargs: Any) -> None: """Activate the scene.""" diff --git a/homeassistant/components/youtube/manifest.json b/homeassistant/components/youtube/manifest.json index 56b0f0fdd3a0e6..f2aaab6862f326 100644 --- a/homeassistant/components/youtube/manifest.json +++ b/homeassistant/components/youtube/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/youtube", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["youtubeaio==2.0.0"] + "requirements": ["youtubeaio==2.1.0"] } diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index 5ef2fd7ab3b55d..818c39cc1e6c83 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -61,7 +61,6 @@ entity_registry as er, floor_registry as fr, issue_registry as ir, - label_registry as lr, location as loc_helper, ) from homeassistant.helpers.singleton import singleton @@ -1514,100 +1513,6 @@ def area_devices(hass: HomeAssistant, area_id_or_name: str) -> Iterable[str]: return [entry.id for entry in entries] -def labels(hass: HomeAssistant, lookup_value: Any = None) -> Iterable[str | None]: - """Return all labels, or those from a area ID, device ID, or entity ID.""" - label_reg = lr.async_get(hass) - if lookup_value is None: - return list(label_reg.labels) - - ent_reg = er.async_get(hass) - - # Import here, not at top-level to avoid circular import - from homeassistant.helpers import config_validation as cv # noqa: PLC0415 - - lookup_value = str(lookup_value) - - try: - cv.entity_id(lookup_value) - except vol.Invalid: - pass - else: - if entity := ent_reg.async_get(lookup_value): - return list(entity.labels) - - # Check if this could be a device ID - dev_reg = dr.async_get(hass) - if device := dev_reg.async_get(lookup_value): - return list(device.labels) - - # Check if this could be a area ID - area_reg = ar.async_get(hass) - if area := area_reg.async_get_area(lookup_value): - return list(area.labels) - - return [] - - -def label_id(hass: HomeAssistant, lookup_value: Any) -> str | None: - """Get the label ID from a label name.""" - label_reg = lr.async_get(hass) - if label := label_reg.async_get_label_by_name(str(lookup_value)): - return label.label_id - return None - - -def label_name(hass: HomeAssistant, lookup_value: str) -> str | None: - """Get the label name from a label ID.""" - label_reg = lr.async_get(hass) - if label := label_reg.async_get_label(lookup_value): - return label.name - return None - - -def label_description(hass: HomeAssistant, lookup_value: str) -> str | None: - """Get the label description from a label ID.""" - label_reg = lr.async_get(hass) - if label := label_reg.async_get_label(lookup_value): - return label.description - return None - - -def _label_id_or_name(hass: HomeAssistant, label_id_or_name: str) -> str | None: - """Get the label ID from a label name or ID.""" - # If label_name returns a value, we know the input was an ID, otherwise we - # assume it's a name, and if it's neither, we return early. - if label_name(hass, label_id_or_name) is not None: - return label_id_or_name - return label_id(hass, label_id_or_name) - - -def label_areas(hass: HomeAssistant, label_id_or_name: str) -> Iterable[str]: - """Return areas for a given label ID or name.""" - if (_label_id := _label_id_or_name(hass, label_id_or_name)) is None: - return [] - area_reg = ar.async_get(hass) - entries = ar.async_entries_for_label(area_reg, _label_id) - return [entry.id for entry in entries] - - -def label_devices(hass: HomeAssistant, label_id_or_name: str) -> Iterable[str]: - """Return device IDs for a given label ID or name.""" - if (_label_id := _label_id_or_name(hass, label_id_or_name)) is None: - return [] - dev_reg = dr.async_get(hass) - entries = dr.async_entries_for_label(dev_reg, _label_id) - return [entry.id for entry in entries] - - -def label_entities(hass: HomeAssistant, label_id_or_name: str) -> Iterable[str]: - """Return entities for a given label ID or name.""" - if (_label_id := _label_id_or_name(hass, label_id_or_name)) is None: - return [] - ent_reg = er.async_get(hass) - entries = er.async_entries_for_label(ent_reg, _label_id) - return [entry.entity_id for entry in entries] - - def closest(hass: HomeAssistant, *args: Any) -> State | None: """Find closest entity. @@ -2454,6 +2359,7 @@ def __init__( "homeassistant.helpers.template.extensions.CollectionExtension" ) self.add_extension("homeassistant.helpers.template.extensions.CryptoExtension") + self.add_extension("homeassistant.helpers.template.extensions.LabelExtension") self.add_extension("homeassistant.helpers.template.extensions.MathExtension") self.add_extension("homeassistant.helpers.template.extensions.RegexExtension") self.add_extension("homeassistant.helpers.template.extensions.StringExtension") @@ -2603,29 +2509,6 @@ def wrapper(_: Any, *args: _P.args, **kwargs: _P.kwargs) -> _R: self.globals["device_id"] = hassfunction(device_id) self.filters["device_id"] = self.globals["device_id"] - # Label extensions - - self.globals["labels"] = hassfunction(labels) - self.filters["labels"] = self.globals["labels"] - - self.globals["label_id"] = hassfunction(label_id) - self.filters["label_id"] = self.globals["label_id"] - - self.globals["label_name"] = hassfunction(label_name) - self.filters["label_name"] = self.globals["label_name"] - - self.globals["label_description"] = hassfunction(label_description) - self.filters["label_description"] = self.globals["label_description"] - - self.globals["label_areas"] = hassfunction(label_areas) - self.filters["label_areas"] = self.globals["label_areas"] - - self.globals["label_devices"] = hassfunction(label_devices) - self.filters["label_devices"] = self.globals["label_devices"] - - self.globals["label_entities"] = hassfunction(label_entities) - self.filters["label_entities"] = self.globals["label_entities"] - # Issue extensions self.globals["issues"] = hassfunction(issues) @@ -2658,8 +2541,6 @@ def warn_unsupported(*args: Any, **kwargs: Any) -> NoReturn: "is_hidden_entity", "is_state_attr", "is_state", - "label_id", - "label_name", "now", "relative_time", "state_attr", @@ -2679,8 +2560,6 @@ def warn_unsupported(*args: Any, **kwargs: Any) -> NoReturn: "floor_id", "floor_name", "has_value", - "label_id", - "label_name", ] hass_tests = [ "has_value", diff --git a/homeassistant/helpers/template/extensions/__init__.py b/homeassistant/helpers/template/extensions/__init__.py index 80a4c1d46f6733..b136bcd18b1f10 100644 --- a/homeassistant/helpers/template/extensions/__init__.py +++ b/homeassistant/helpers/template/extensions/__init__.py @@ -3,6 +3,7 @@ from .base64 import Base64Extension from .collection import CollectionExtension from .crypto import CryptoExtension +from .labels import LabelExtension from .math import MathExtension from .regex import RegexExtension from .string import StringExtension @@ -11,6 +12,7 @@ "Base64Extension", "CollectionExtension", "CryptoExtension", + "LabelExtension", "MathExtension", "RegexExtension", "StringExtension", diff --git a/homeassistant/helpers/template/extensions/base.py b/homeassistant/helpers/template/extensions/base.py index 0449cf974fc88a..5aae08e33d9178 100644 --- a/homeassistant/helpers/template/extensions/base.py +++ b/homeassistant/helpers/template/extensions/base.py @@ -4,8 +4,10 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, NoReturn +from functools import wraps +from typing import TYPE_CHECKING, Any, Concatenate, NoReturn +from jinja2 import pass_context from jinja2.ext import Extension from jinja2.nodes import Node from jinja2.parser import Parser @@ -32,6 +34,28 @@ class TemplateFunction: requires_hass: bool = False # Whether this function requires hass to be available +def _pass_context[**_P, _R]( + func: Callable[Concatenate[Any, _P], _R], + jinja_context: Callable[ + [Callable[Concatenate[Any, _P], _R]], + Callable[Concatenate[Any, _P], _R], + ] = pass_context, +) -> Callable[Concatenate[Any, _P], _R]: + """Wrap function to pass context. + + We mark these as a context functions to ensure they get + evaluated fresh with every execution, rather than executed + at compile time and the value stored. The context itself + can be discarded. + """ + + @wraps(func) + def wrapper(_: Any, *args: _P.args, **kwargs: _P.kwargs) -> _R: + return func(*args, **kwargs) + + return jinja_context(wrapper) + + class BaseTemplateExtension(Extension): """Base class for Home Assistant template extensions.""" @@ -65,12 +89,20 @@ def __init__( environment.tests[template_func.name] = unsupported_func continue + func = template_func.func + + if template_func.requires_hass: + # We wrap these as a context functions to ensure they get + # evaluated fresh with every execution, rather than executed + # at compile time and the value stored. + func = _pass_context(func) + if template_func.as_global: - environment.globals[template_func.name] = template_func.func + environment.globals[template_func.name] = func if template_func.as_filter: - environment.filters[template_func.name] = template_func.func + environment.filters[template_func.name] = func if template_func.as_test: - environment.tests[template_func.name] = template_func.func + environment.tests[template_func.name] = func @staticmethod def _create_unsupported_function(name: str) -> Callable[[], NoReturn]: diff --git a/homeassistant/helpers/template/extensions/labels.py b/homeassistant/helpers/template/extensions/labels.py new file mode 100644 index 00000000000000..252f12b2305ffa --- /dev/null +++ b/homeassistant/helpers/template/extensions/labels.py @@ -0,0 +1,169 @@ +"""Label functions for Home Assistant templates.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any + +import voluptuous as vol + +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, + label_registry as lr, +) + +from .base import BaseTemplateExtension, TemplateFunction + +if TYPE_CHECKING: + from homeassistant.helpers.template import TemplateEnvironment + + +class LabelExtension(BaseTemplateExtension): + """Extension for label-related template functions.""" + + def __init__(self, environment: TemplateEnvironment) -> None: + """Initialize the label extension.""" + super().__init__( + environment, + functions=[ + TemplateFunction( + "labels", + self.labels, + as_global=True, + as_filter=True, + requires_hass=True, + ), + TemplateFunction( + "label_id", + self.label_id, + as_global=True, + as_filter=True, + requires_hass=True, + limited_ok=False, + ), + TemplateFunction( + "label_name", + self.label_name, + as_global=True, + as_filter=True, + requires_hass=True, + limited_ok=False, + ), + TemplateFunction( + "label_description", + self.label_description, + as_global=True, + as_filter=True, + requires_hass=True, + ), + TemplateFunction( + "label_areas", + self.label_areas, + as_global=True, + as_filter=True, + requires_hass=True, + ), + TemplateFunction( + "label_devices", + self.label_devices, + as_global=True, + as_filter=True, + requires_hass=True, + ), + TemplateFunction( + "label_entities", + self.label_entities, + as_global=True, + as_filter=True, + requires_hass=True, + ), + ], + ) + + def labels(self, lookup_value: Any = None) -> Iterable[str | None]: + """Return all labels, or those from a area ID, device ID, or entity ID.""" + label_reg = lr.async_get(self.hass) + if lookup_value is None: + return list(label_reg.labels) + + ent_reg = er.async_get(self.hass) + + # Import here, not at top-level to avoid circular import + from homeassistant.helpers import config_validation as cv # noqa: PLC0415 + + lookup_value = str(lookup_value) + + try: + cv.entity_id(lookup_value) + except vol.Invalid: + pass + else: + if entity := ent_reg.async_get(lookup_value): + return list(entity.labels) + + # Check if this could be a device ID + dev_reg = dr.async_get(self.hass) + if device := dev_reg.async_get(lookup_value): + return list(device.labels) + + # Check if this could be a area ID + area_reg = ar.async_get(self.hass) + if area := area_reg.async_get_area(lookup_value): + return list(area.labels) + + return [] + + def label_id(self, lookup_value: Any) -> str | None: + """Get the label ID from a label name.""" + label_reg = lr.async_get(self.hass) + if label := label_reg.async_get_label_by_name(str(lookup_value)): + return label.label_id + return None + + def label_name(self, lookup_value: str) -> str | None: + """Get the label name from a label ID.""" + label_reg = lr.async_get(self.hass) + if label := label_reg.async_get_label(lookup_value): + return label.name + return None + + def label_description(self, lookup_value: str) -> str | None: + """Get the label description from a label ID.""" + label_reg = lr.async_get(self.hass) + if label := label_reg.async_get_label(lookup_value): + return label.description + return None + + def _label_id_or_name(self, label_id_or_name: str) -> str | None: + """Get the label ID from a label name or ID.""" + # If label_name returns a value, we know the input was an ID, otherwise we + # assume it's a name, and if it's neither, we return early. + if self.label_name(label_id_or_name) is not None: + return label_id_or_name + return self.label_id(label_id_or_name) + + def label_areas(self, label_id_or_name: str) -> Iterable[str]: + """Return areas for a given label ID or name.""" + if (_label_id := self._label_id_or_name(label_id_or_name)) is None: + return [] + area_reg = ar.async_get(self.hass) + entries = ar.async_entries_for_label(area_reg, _label_id) + return [entry.id for entry in entries] + + def label_devices(self, label_id_or_name: str) -> Iterable[str]: + """Return device IDs for a given label ID or name.""" + if (_label_id := self._label_id_or_name(label_id_or_name)) is None: + return [] + dev_reg = dr.async_get(self.hass) + entries = dr.async_entries_for_label(dev_reg, _label_id) + return [entry.id for entry in entries] + + def label_entities(self, label_id_or_name: str) -> Iterable[str]: + """Return entities for a given label ID or name.""" + if (_label_id := self._label_id_or_name(label_id_or_name)) is None: + return [] + ent_reg = er.async_get(self.hass) + entries = er.async_entries_for_label(ent_reg, _label_id) + return [entry.entity_id for entry in entries] diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 27164e8fcf395d..eefbcda53357cd 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -42,6 +42,16 @@ class UpdateFailed(HomeAssistantError): """Raised when an update has failed.""" + def __init__( + self, + *args: Any, + retry_after: float | None = None, + **kwargs: Any, + ) -> None: + """Initialize exception.""" + super().__init__(*args, **kwargs) + self.retry_after = retry_after + class BaseDataUpdateCoordinatorProtocol(Protocol): """Base protocol type for DataUpdateCoordinator.""" @@ -119,6 +129,7 @@ def __init__( self._unsub_refresh: CALLBACK_TYPE | None = None self._unsub_shutdown: CALLBACK_TYPE | None = None self._request_refresh_task: asyncio.TimerHandle | None = None + self._retry_after: float | None = None self.last_update_success = True self.last_exception: Exception | None = None @@ -250,9 +261,12 @@ def _schedule_refresh(self) -> None: hass = self.hass loop = hass.loop - next_refresh = ( - int(loop.time()) + self._microsecond + self._update_interval_seconds - ) + update_interval = self._update_interval_seconds + if self._retry_after is not None: + update_interval = self._retry_after + self._retry_after = None + + next_refresh = int(loop.time()) + self._microsecond + update_interval self._unsub_refresh = loop.call_at( next_refresh, self.__wrap_handle_refresh_interval ).cancel @@ -327,7 +341,9 @@ async def _async_config_entry_first_refresh(self) -> None: ) if await self.__wrap_async_setup(): await self._async_refresh( - log_failures=False, raise_on_auth_failed=True, raise_on_entry_error=True + log_failures=False, + raise_on_auth_failed=True, + raise_on_entry_error=True, ) if self.last_update_success: return @@ -430,6 +446,16 @@ async def _async_refresh( # noqa: C901 except UpdateFailed as err: self.last_exception = err + # We can only honor a retry_after, after the config entry has been set up + # Basically meaning that the retry after can't be used when coming + # from an async_config_entry_first_refresh + if err.retry_after is not None and not raise_on_entry_error: + self._retry_after = err.retry_after + self.logger.debug( + "Retry after triggered. Scheduling next update in %s second(s)", + err.retry_after, + ) + if self.last_update_success: if log_failures: self.logger.error("Error fetching %s data: %s", self.name, err) diff --git a/requirements_all.txt b/requirements_all.txt index d5e30601c8b9bd..76cedfd76250d1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3204,7 +3204,7 @@ yolink-api==0.5.8 youless-api==2.2.0 # homeassistant.components.youtube -youtubeaio==2.0.0 +youtubeaio==2.1.0 # homeassistant.components.media_extractor yt-dlp[default]==2025.10.22 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 01d51047088519..0c788c02c4a62c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2650,7 +2650,7 @@ yolink-api==0.5.8 youless-api==2.2.0 # homeassistant.components.youtube -youtubeaio==2.0.0 +youtubeaio==2.1.0 # homeassistant.components.media_extractor yt-dlp[default]==2025.10.22 diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index 58d6dae2900c3c..ffb67949d18829 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -190,7 +190,7 @@ def get_settings_side_effect(ha_id: str): ) -@pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True) +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) async def test_number_entity_availability( hass: HomeAssistant, client: MagicMock, @@ -200,8 +200,19 @@ async def test_number_entity_availability( ) -> None: """Test if number entities availability are based on the appliance connection state.""" entity_ids = [ - f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + f"{NUMBER_DOMAIN.lower()}.oven_alarm_clock", + f"{NUMBER_DOMAIN.lower()}.oven_setpoint_temperature", ] + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE, "Boolean" + ) + ], + ) + ) client.get_setting.side_effect = None # Setting constrains are not needed for this test @@ -616,3 +627,133 @@ async def set_program_option_side_effect(ha_id: str, *_, **kwargs) -> None: "value": 80, } assert hass.states.is_state(entity_id, "80.0") + + +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) +async def test_options_unavailable_when_option_is_missing( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, +) -> None: + """Test that option entities become unavailable when the option is missing.""" + entity_id = "number.oven_setpoint_temperature" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE, "Double" + ) + ], + ) + ) + + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.COOKING_OVEN_HEATING_MODE_INTENSIVE_HEAT, + options=[], + ) + ) + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value, + 0, + level="info", + handling="auto", + value=ProgramKey.COOKING_OVEN_HEATING_MODE_INTENSIVE_HEAT, + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) +@pytest.mark.parametrize( + "event_key", + [ + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ], +) +async def test_options_available_when_program_is_null( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + event_key: EventKey, +) -> None: + """Test that option entities still available when the active program becomes null. + + This can happen when the appliance starts or finish the program; the appliance first + updates the non-null program, and then the null program value. + This test ensures that the options defined by the non-null program are not removed + from the coordinator and therefore, the entities remain available. + """ + entity_id = "number.oven_setpoint_temperature" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE, "Double" + ) + ], + ) + ) + + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + event_key, + event_key.value, + 0, + level="info", + handling="auto", + value=None, + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index 80f2ecaa7200ea..9467a092e1d3d7 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -215,9 +215,17 @@ async def test_select_entity_availability( appliance: HomeAppliance, ) -> None: """Test if select entities availability are based on the appliance connection state.""" - entity_ids = [ - "select.washer_active_program", - ] + entity_ids = ["select.washer_active_program", "select.washer_temperature"] + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, "Boolean" + ) + ], + ) + ) assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED @@ -967,3 +975,133 @@ async def test_options_functionality( assert hass.states.is_state( entity_id, "laundry_care_washer_enum_type_temperature_ul_warm" ) + + +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_options_unavailable_when_option_is_missing( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, +) -> None: + """Test that option entities become unavailable when the option is missing.""" + entity_id = "select.washer_temperature" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, "Boolean" + ) + ], + ) + ) + + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.LAUNDRY_CARE_WASHER_AUTO_30, + options=[], + ) + ) + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value, + 0, + level="info", + handling="auto", + value=ProgramKey.LAUNDRY_CARE_WASHER_AUTO_30, + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +@pytest.mark.parametrize( + "event_key", + [ + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ], +) +async def test_options_available_when_program_is_null( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + event_key: EventKey, +) -> None: + """Test that option entities still available when the active program becomes null. + + This can happen when the appliance starts or finish the program; the appliance first + updates the non-null program, and then the null program value. + This test ensures that the options defined by the non-null program are not removed + from the coordinator and therefore, the entities remain available. + """ + entity_id = "select.washer_temperature" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, "Enumeration" + ) + ], + ) + ) + + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + event_key, + event_key.value, + 0, + level="info", + handling="auto", + value=None, + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index 1131f0ab46ecd8..f98eb0fd52184f 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -7,6 +7,7 @@ from aiohomeconnect.model import ( ArrayOfEvents, ArrayOfSettings, + Event, EventMessage, EventType, GetSetting, @@ -31,6 +32,7 @@ BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN, + EventKey, ) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -191,7 +193,18 @@ async def test_switch_entity_availability( entity_ids = [ "switch.dishwasher_power", "switch.dishwasher_child_lock", + "switch.dishwasher_half_load", ] + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD, "Boolean" + ) + ], + ) + ) assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED @@ -735,3 +748,133 @@ async def test_options_functionality( "value": True, } assert hass.states.is_state(entity_id, STATE_ON) + + +@pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True) +async def test_options_unavailable_when_option_is_missing( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, +) -> None: + """Test that option entities become unavailable when the option is missing.""" + entity_id = "switch.dishwasher_half_load" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD, "Boolean" + ) + ], + ) + ) + + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.DISHCARE_DISHWASHER_AUTO_1, + options=[], + ) + ) + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value, + 0, + level="info", + handling="auto", + value=ProgramKey.DISHCARE_DISHWASHER_AUTO_1, + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True) +@pytest.mark.parametrize( + "event_key", + [ + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ], +) +async def test_options_available_when_program_is_null( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + event_key: EventKey, +) -> None: + """Test that option entities still available when the active program becomes null. + + This can happen when the appliance starts or finish the program; the appliance first + updates the non-null program, and then the null program value. + This test ensures that the options defined by the non-null program are not removed + from the coordinator and therefore, the entities remain available. + """ + entity_id = "switch.dishwasher_half_load" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD, "Boolean" + ) + ], + ) + ) + + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + event_key, + event_key.value, + 0, + level="info", + handling="auto", + value=None, + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 829110717a7743..96cc09a4727cf1 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -6468,7 +6468,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Remote controller (unsupported)', + 'model': 'Remote controller', 'model_id': 'bngwdjsr', 'name': 'Télécommande lumières ZigBee', 'name_by_user': None, diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index f972c100766a93..b2b0d1fe2f9a1c 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -3582,6 +3582,77 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[light.telecommande_lumieres_zigbee-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.telecommande_lumieres_zigbee', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.rsjdwgnbqkyswitch_controller', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.telecommande_lumieres_zigbee-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Télécommande lumières ZigBee', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.telecommande_lumieres_zigbee', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_platform_setup_and_discovery[light.tower_fan_ca_407g_smart_backlight-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py index b377da6162d43c..f4a7192b67c5b3 100644 --- a/tests/components/velux/conftest.py +++ b/tests/components/velux/conftest.py @@ -8,6 +8,7 @@ from homeassistant.components.velux import DOMAIN from homeassistant.components.velux.binary_sensor import Window from homeassistant.components.velux.light import LighteningDevice +from homeassistant.components.velux.scene import PyVLXScene as Scene from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant @@ -91,10 +92,23 @@ def mock_light() -> AsyncMock: @pytest.fixture -def mock_pyvlx(mock_window: MagicMock, mock_light: MagicMock) -> Generator[MagicMock]: +def mock_scene() -> AsyncMock: + """Create a mock Velux scene.""" + scene = AsyncMock(spec=Scene, autospec=True) + scene.name = "Test Scene" + scene.scene_id = "1234" + scene.scene = AsyncMock() + return scene + + +@pytest.fixture +def mock_pyvlx( + mock_window: MagicMock, mock_light: MagicMock, mock_scene: AsyncMock +) -> Generator[MagicMock]: """Create the library mock and patch PyVLX.""" pyvlx = MagicMock() pyvlx.nodes = [mock_window, mock_light] + pyvlx.scenes = [mock_scene] pyvlx.load_scenes = AsyncMock() pyvlx.load_nodes = AsyncMock() pyvlx.disconnect = AsyncMock() diff --git a/tests/components/velux/snapshots/test_scene.ambr b/tests/components/velux/snapshots/test_scene.ambr new file mode 100644 index 00000000000000..5884515b91b901 --- /dev/null +++ b/tests/components/velux/snapshots/test_scene.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_scene_snapshot[scene.klf_200_gateway_test_scene-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'scene', + 'entity_category': None, + 'entity_id': 'scene.klf_200_gateway_test_scene', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Test Scene', + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test_entry_id_scene_1234', + 'unit_of_measurement': None, + }) +# --- +# name: test_scene_snapshot[scene.klf_200_gateway_test_scene-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'KLF 200 Gateway Test Scene', + }), + 'context': , + 'entity_id': 'scene.klf_200_gateway_test_scene', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/velux/test_light.py b/tests/components/velux/test_light.py index 4f8f168aaafada..94c0d2858b020c 100644 --- a/tests/components/velux/test_light.py +++ b/tests/components/velux/test_light.py @@ -8,6 +8,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from tests.common import MockConfigEntry + @pytest.fixture def platform() -> Platform: @@ -41,3 +43,30 @@ async def test_light_setup( # Verify device has correct identifiers + name assert ("velux", mock_light.serial_number) in device_entry.identifiers assert device_entry.name == mock_light.name + + +# This test is not light specific, it just uses the light platform to test the base entity class. +@pytest.mark.usefixtures("setup_integration") +async def test_entity_callbacks( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_light: AsyncMock, +) -> None: + """Ensure the entity unregisters its device-updated callback when unloaded.""" + # Entity is created by setup_integration; callback should be registered + test_entity_id = f"light.{mock_light.name.lower().replace(' ', '_')}" + state = hass.states.get(test_entity_id) + assert state is not None + + # Callback is registered exactly once with a callable + assert mock_light.register_device_updated_cb.call_count == 1 + cb = mock_light.register_device_updated_cb.call_args[0][0] + assert callable(cb) + + # Unload the config entry to trigger async_will_remove_from_hass + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Callback must be unregistered with the same callable + assert mock_light.unregister_device_updated_cb.call_count == 1 + assert mock_light.unregister_device_updated_cb.call_args[0][0] is cb diff --git a/tests/components/velux/test_scene.py b/tests/components/velux/test_scene.py new file mode 100644 index 00000000000000..52da8d84ac9ebf --- /dev/null +++ b/tests/components/velux/test_scene.py @@ -0,0 +1,68 @@ +"""Test Velux scene entities.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, SERVICE_TURN_ON +from homeassistant.components.velux import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import AsyncMock, MockConfigEntry, snapshot_platform + + +@pytest.fixture +def platform() -> Platform: + """Fixture to specify platform to test.""" + return Platform.SCENE + + +@pytest.mark.usefixtures("setup_integration") +async def test_scene_snapshot( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Snapshot the scene entity (registry + state).""" + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_config_entry.entry_id, + ) + + # Get the scene entity setup and test device association + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entity_entries) == 1 + entry = entity_entries[0] + + assert entry.device_id is not None + device_entry = device_registry.async_get(entry.device_id) + assert device_entry is not None + # Scenes are associated with the gateway device + assert (DOMAIN, f"gateway_{mock_config_entry.entry_id}") in device_entry.identifiers + assert device_entry.via_device_id is None + + +@pytest.mark.usefixtures("setup_integration") +async def test_scene_activation( + hass: HomeAssistant, + mock_scene: AsyncMock, +) -> None: + """Test successful scene activation.""" + + # activate the scene via service call + await hass.services.async_call( + SCENE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "scene.klf_200_gateway_test_scene"}, + blocking=True, + ) + + # Verify the run method was called + mock_scene.run.assert_awaited_once_with(wait_for_completion=False) diff --git a/tests/helpers/template/extensions/test_labels.py b/tests/helpers/template/extensions/test_labels.py new file mode 100644 index 00000000000000..071ab1cc76cf26 --- /dev/null +++ b/tests/helpers/template/extensions/test_labels.py @@ -0,0 +1,408 @@ +"""Test label template functions.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, + label_registry as lr, +) + +from tests.common import MockConfigEntry +from tests.helpers.template.helpers import assert_result_info, render_to_info + + +async def test_labels( + hass: HomeAssistant, + label_registry: lr.LabelRegistry, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test labels function.""" + + # Test no labels + info = render_to_info(hass, "{{ labels() }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test one label + label1 = label_registry.async_create("label1") + info = render_to_info(hass, "{{ labels() }}") + assert_result_info(info, [label1.label_id]) + assert info.rate_limit is None + + # Test multiple label + label2 = label_registry.async_create("label2") + info = render_to_info(hass, "{{ labels() }}") + assert_result_info(info, [label1.label_id, label2.label_id]) + assert info.rate_limit is None + + # Test non-existing entity ID + info = render_to_info(hass, "{{ labels('sensor.fake') }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 'sensor.fake' | labels }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test non existing device ID (hex value) + info = render_to_info(hass, "{{ labels('123abc') }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ '123abc' | labels }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Create a device & entity for testing + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry, + device_id=device_entry.id, + ) + + # Test entity, which has no labels + info = render_to_info(hass, f"{{{{ labels('{entity_entry.entity_id}') }}}}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{entity_entry.entity_id}' | labels }}}}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test device, which has no labels + info = render_to_info(hass, f"{{{{ labels('{device_entry.id}') }}}}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{device_entry.id}' | labels }}}}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Add labels to the entity & device + device_entry = device_registry.async_update_device( + device_entry.id, labels=[label1.label_id] + ) + entity_entry = entity_registry.async_update_entity( + entity_entry.entity_id, labels=[label2.label_id] + ) + + # Test entity, which now has a label + info = render_to_info(hass, f"{{{{ '{entity_entry.entity_id}' | labels }}}}") + assert_result_info(info, [label2.label_id]) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ labels('{entity_entry.entity_id}') }}}}") + assert_result_info(info, [label2.label_id]) + assert info.rate_limit is None + + # Test device, which now has a label + info = render_to_info(hass, f"{{{{ '{device_entry.id}' | labels }}}}") + assert_result_info(info, [label1.label_id]) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ labels('{device_entry.id}') }}}}") + assert_result_info(info, [label1.label_id]) + assert info.rate_limit is None + + # Create area for testing + area = area_registry.async_create("living room") + + # Test area, which has no labels + info = render_to_info(hass, f"{{{{ '{area.id}' | labels }}}}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ labels('{area.id}') }}}}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Add label to the area + area_registry.async_update(area.id, labels=[label1.label_id, label2.label_id]) + + # Test area, which now has labels + info = render_to_info(hass, f"{{{{ '{area.id}' | labels }}}}") + assert_result_info(info, [label1.label_id, label2.label_id]) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ labels('{area.id}') }}}}") + assert_result_info(info, [label1.label_id, label2.label_id]) + assert info.rate_limit is None + + +async def test_label_id( + hass: HomeAssistant, + label_registry: lr.LabelRegistry, +) -> None: + """Test label_id function.""" + # Test non existing label name + info = render_to_info(hass, "{{ label_id('non-existing label') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 'non-existing label' | label_id }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ label_id(42) }}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 42 | label_id }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test with an actual label + label = label_registry.async_create("existing label") + info = render_to_info(hass, "{{ label_id('existing label') }}") + assert_result_info(info, label.label_id) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 'existing label' | label_id }}") + assert_result_info(info, label.label_id) + assert info.rate_limit is None + + +async def test_label_name( + hass: HomeAssistant, + label_registry: lr.LabelRegistry, +) -> None: + """Test label_name function.""" + # Test non existing label ID + info = render_to_info(hass, "{{ label_name('1234567890') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ '1234567890' | label_name }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ label_name(42) }}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 42 | label_name }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test non existing label ID + label = label_registry.async_create("choo choo") + info = render_to_info(hass, f"{{{{ label_name('{label.label_id}') }}}}") + assert_result_info(info, label.name) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{label.label_id}' | label_name }}}}") + assert_result_info(info, label.name) + assert info.rate_limit is None + + +async def test_label_description( + hass: HomeAssistant, + label_registry: lr.LabelRegistry, +) -> None: + """Test label_description function.""" + # Test non existing label ID + info = render_to_info(hass, "{{ label_description('1234567890') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ '1234567890' | label_description }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ label_description(42) }}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 42 | label_description }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test valid label ID + label = label_registry.async_create("choo choo", description="chugga chugga") + info = render_to_info(hass, f"{{{{ label_description('{label.label_id}') }}}}") + assert_result_info(info, label.description) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{label.label_id}' | label_description }}}}") + assert_result_info(info, label.description) + assert info.rate_limit is None + + +async def test_label_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + label_registry: lr.LabelRegistry, +) -> None: + """Test label_entities function.""" + + # Test non existing device ID + info = render_to_info(hass, "{{ label_entities('deadbeef') }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 'deadbeef' | label_entities }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ label_entities(42) }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 42 | label_entities }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Create a fake config entry with a entity + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry, + ) + + # Add a label to the entity + label = label_registry.async_create("Romantic Lights") + entity_registry.async_update_entity(entity_entry.entity_id, labels={label.label_id}) + + # Get entities by label ID + info = render_to_info(hass, f"{{{{ label_entities('{label.label_id}') }}}}") + assert_result_info(info, ["light.hue_5678"]) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{label.label_id}' | label_entities }}}}") + assert_result_info(info, ["light.hue_5678"]) + assert info.rate_limit is None + + # Get entities by label name + info = render_to_info(hass, f"{{{{ label_entities('{label.name}') }}}}") + assert_result_info(info, ["light.hue_5678"]) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{label.name}' | label_entities }}}}") + assert_result_info(info, ["light.hue_5678"]) + assert info.rate_limit is None + + +async def test_label_devices( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + label_registry: lr.LabelRegistry, +) -> None: + """Test label_devices function.""" + + # Test non existing device ID + info = render_to_info(hass, "{{ label_devices('deadbeef') }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 'deadbeef' | label_devices }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ label_devices(42) }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 42 | label_devices }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Create a fake config entry with a device + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + # Add a label to it + label = label_registry.async_create("Romantic Lights") + device_registry.async_update_device(device_entry.id, labels=[label.label_id]) + + # Get the devices from a label by its ID + info = render_to_info(hass, f"{{{{ label_devices('{label.label_id}') }}}}") + assert_result_info(info, [device_entry.id]) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{label.label_id}' | label_devices }}}}") + assert_result_info(info, [device_entry.id]) + assert info.rate_limit is None + + # Get the devices from a label by its name + info = render_to_info(hass, f"{{{{ label_devices('{label.name}') }}}}") + assert_result_info(info, [device_entry.id]) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{label.name}' | label_devices }}}}") + assert_result_info(info, [device_entry.id]) + assert info.rate_limit is None + + +async def test_label_areas( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + label_registry: lr.LabelRegistry, +) -> None: + """Test label_areas function.""" + + # Test non existing area ID + info = render_to_info(hass, "{{ label_areas('deadbeef') }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 'deadbeef' | label_areas }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ label_areas(42) }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 42 | label_areas }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Create an area with an label + label = label_registry.async_create("Upstairs") + master_bedroom = area_registry.async_create( + "Master Bedroom", labels=[label.label_id] + ) + + # Get areas by label ID + info = render_to_info(hass, f"{{{{ label_areas('{label.label_id}') }}}}") + assert_result_info(info, [master_bedroom.id]) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{label.label_id}' | label_areas }}}}") + assert_result_info(info, [master_bedroom.id]) + assert info.rate_limit is None + + # Get areas by label name + info = render_to_info(hass, f"{{{{ label_areas('{label.name}') }}}}") + assert_result_info(info, [master_bedroom.id]) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{label.name}' | label_areas }}}}") + assert_result_info(info, [master_bedroom.id]) + assert info.rate_limit is None diff --git a/tests/helpers/template/helpers.py b/tests/helpers/template/helpers.py index f15e63b0b090d0..bdbd56315a12b8 100644 --- a/tests/helpers/template/helpers.py +++ b/tests/helpers/template/helpers.py @@ -44,7 +44,13 @@ def assert_result_info( all_states: bool = False, ) -> None: """Check result info.""" - assert info.result() == result + actual = info.result() + assert actual == result, ( + f"Template result mismatch:\n" + f" Expected: {result!r} (type: {type(result).__name__})\n" + f" Actual: {actual!r} (type: {type(actual).__name__})\n" + f" Template: {info.template!r}" + ) assert info.all_states == all_states assert info.filter("invalid_entity_name.somewhere") == all_states if entities is not None: diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index 24ca90a643bf7b..7548dd283e1dc8 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -42,7 +42,6 @@ entity_registry as er, floor_registry as fr, issue_registry as ir, - label_registry as lr, template, translation, ) @@ -4703,400 +4702,6 @@ async def test_floor_entities( assert info.rate_limit is None -async def test_labels( - hass: HomeAssistant, - label_registry: lr.LabelRegistry, - area_registry: ar.AreaRegistry, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test labels function.""" - - # Test no labels - info = render_to_info(hass, "{{ labels() }}") - assert_result_info(info, []) - assert info.rate_limit is None - - # Test one label - label1 = label_registry.async_create("label1") - info = render_to_info(hass, "{{ labels() }}") - assert_result_info(info, [label1.label_id]) - assert info.rate_limit is None - - # Test multiple label - label2 = label_registry.async_create("label2") - info = render_to_info(hass, "{{ labels() }}") - assert_result_info(info, [label1.label_id, label2.label_id]) - assert info.rate_limit is None - - # Test non-exsting entity ID - info = render_to_info(hass, "{{ labels('sensor.fake') }}") - assert_result_info(info, []) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ 'sensor.fake' | labels }}") - assert_result_info(info, []) - assert info.rate_limit is None - - # Test non existing device ID (hex value) - info = render_to_info(hass, "{{ labels('123abc') }}") - assert_result_info(info, []) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ '123abc' | labels }}") - assert_result_info(info, []) - assert info.rate_limit is None - - # Create a device & entity for testing - config_entry = MockConfigEntry(domain="light") - config_entry.add_to_hass(hass) - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - entity_entry = entity_registry.async_get_or_create( - "light", - "hue", - "5678", - config_entry=config_entry, - device_id=device_entry.id, - ) - - # Test entity, which has no labels - info = render_to_info(hass, f"{{{{ labels('{entity_entry.entity_id}') }}}}") - assert_result_info(info, []) - assert info.rate_limit is None - - info = render_to_info(hass, f"{{{{ '{entity_entry.entity_id}' | labels }}}}") - assert_result_info(info, []) - assert info.rate_limit is None - - # Test device, which has no labels - info = render_to_info(hass, f"{{{{ labels('{device_entry.id}') }}}}") - assert_result_info(info, []) - assert info.rate_limit is None - - info = render_to_info(hass, f"{{{{ '{device_entry.id}' | labels }}}}") - assert_result_info(info, []) - assert info.rate_limit is None - - # Add labels to the entity & device - device_entry = device_registry.async_update_device( - device_entry.id, labels=[label1.label_id] - ) - entity_entry = entity_registry.async_update_entity( - entity_entry.entity_id, labels=[label2.label_id] - ) - - # Test entity, which now has a label - info = render_to_info(hass, f"{{{{ '{entity_entry.entity_id}' | labels }}}}") - assert_result_info(info, [label2.label_id]) - assert info.rate_limit is None - - info = render_to_info(hass, f"{{{{ labels('{entity_entry.entity_id}') }}}}") - assert_result_info(info, [label2.label_id]) - assert info.rate_limit is None - - # Test device, which now has a label - info = render_to_info(hass, f"{{{{ '{device_entry.id}' | labels }}}}") - assert_result_info(info, [label1.label_id]) - assert info.rate_limit is None - - info = render_to_info(hass, f"{{{{ labels('{device_entry.id}') }}}}") - assert_result_info(info, [label1.label_id]) - assert info.rate_limit is None - - # Create area for testing - area = area_registry.async_create("living room") - - # Test area, which has no labels - info = render_to_info(hass, f"{{{{ '{area.id}' | labels }}}}") - assert_result_info(info, []) - assert info.rate_limit is None - - info = render_to_info(hass, f"{{{{ labels('{area.id}') }}}}") - assert_result_info(info, []) - assert info.rate_limit is None - - # Add label to the area - area_registry.async_update(area.id, labels=[label1.label_id, label2.label_id]) - - # Test area, which now has labels - info = render_to_info(hass, f"{{{{ '{area.id}' | labels }}}}") - assert_result_info(info, [label1.label_id, label2.label_id]) - assert info.rate_limit is None - - info = render_to_info(hass, f"{{{{ labels('{area.id}') }}}}") - assert_result_info(info, [label1.label_id, label2.label_id]) - assert info.rate_limit is None - - -async def test_label_id( - hass: HomeAssistant, - label_registry: lr.LabelRegistry, -) -> None: - """Test label_id function.""" - # Test non existing label name - info = render_to_info(hass, "{{ label_id('non-existing label') }}") - assert_result_info(info, None) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ 'non-existing label' | label_id }}") - assert_result_info(info, None) - assert info.rate_limit is None - - # Test wrong value type - info = render_to_info(hass, "{{ label_id(42) }}") - assert_result_info(info, None) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ 42 | label_id }}") - assert_result_info(info, None) - assert info.rate_limit is None - - # Test with an actual label - label = label_registry.async_create("existing label") - info = render_to_info(hass, "{{ label_id('existing label') }}") - assert_result_info(info, label.label_id) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ 'existing label' | label_id }}") - assert_result_info(info, label.label_id) - assert info.rate_limit is None - - -async def test_label_name( - hass: HomeAssistant, - label_registry: lr.LabelRegistry, -) -> None: - """Test label_name function.""" - # Test non existing label ID - info = render_to_info(hass, "{{ label_name('1234567890') }}") - assert_result_info(info, None) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ '1234567890' | label_name }}") - assert_result_info(info, None) - assert info.rate_limit is None - - # Test wrong value type - info = render_to_info(hass, "{{ label_name(42) }}") - assert_result_info(info, None) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ 42 | label_name }}") - assert_result_info(info, None) - assert info.rate_limit is None - - # Test non existing label ID - label = label_registry.async_create("choo choo") - info = render_to_info(hass, f"{{{{ label_name('{label.label_id}') }}}}") - assert_result_info(info, label.name) - assert info.rate_limit is None - - info = render_to_info(hass, f"{{{{ '{label.label_id}' | label_name }}}}") - assert_result_info(info, label.name) - assert info.rate_limit is None - - -async def test_label_description( - hass: HomeAssistant, - label_registry: lr.LabelRegistry, -) -> None: - """Test label_description function.""" - # Test non existing label ID - info = render_to_info(hass, "{{ label_description('1234567890') }}") - assert_result_info(info, None) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ '1234567890' | label_description }}") - assert_result_info(info, None) - assert info.rate_limit is None - - # Test wrong value type - info = render_to_info(hass, "{{ label_description(42) }}") - assert_result_info(info, None) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ 42 | label_description }}") - assert_result_info(info, None) - assert info.rate_limit is None - - # Test valid label ID - label = label_registry.async_create("choo choo", description="chugga chugga") - info = render_to_info(hass, f"{{{{ label_description('{label.label_id}') }}}}") - assert_result_info(info, label.description) - assert info.rate_limit is None - - info = render_to_info(hass, f"{{{{ '{label.label_id}' | label_description }}}}") - assert_result_info(info, label.description) - assert info.rate_limit is None - - -async def test_label_entities( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - label_registry: lr.LabelRegistry, -) -> None: - """Test label_entities function.""" - - # Test non existing device ID - info = render_to_info(hass, "{{ label_entities('deadbeef') }}") - assert_result_info(info, []) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ 'deadbeef' | label_entities }}") - assert_result_info(info, []) - assert info.rate_limit is None - - # Test wrong value type - info = render_to_info(hass, "{{ label_entities(42) }}") - assert_result_info(info, []) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ 42 | label_entities }}") - assert_result_info(info, []) - assert info.rate_limit is None - - # Create a fake config entry with a entity - config_entry = MockConfigEntry(domain="light") - config_entry.add_to_hass(hass) - entity_entry = entity_registry.async_get_or_create( - "light", - "hue", - "5678", - config_entry=config_entry, - ) - - # Add a label to the entity - label = label_registry.async_create("Romantic Lights") - entity_registry.async_update_entity(entity_entry.entity_id, labels={label.label_id}) - - # Get entities by label ID - info = render_to_info(hass, f"{{{{ label_entities('{label.label_id}') }}}}") - assert_result_info(info, ["light.hue_5678"]) - assert info.rate_limit is None - - info = render_to_info(hass, f"{{{{ '{label.label_id}' | label_entities }}}}") - assert_result_info(info, ["light.hue_5678"]) - assert info.rate_limit is None - - # Get entities by label name - info = render_to_info(hass, f"{{{{ label_entities('{label.name}') }}}}") - assert_result_info(info, ["light.hue_5678"]) - assert info.rate_limit is None - - info = render_to_info(hass, f"{{{{ '{label.name}' | label_entities }}}}") - assert_result_info(info, ["light.hue_5678"]) - assert info.rate_limit is None - - -async def test_label_devices( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - label_registry: ar.AreaRegistry, -) -> None: - """Test label_devices function.""" - - # Test non existing device ID - info = render_to_info(hass, "{{ label_devices('deadbeef') }}") - assert_result_info(info, []) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ 'deadbeef' | label_devices }}") - assert_result_info(info, []) - assert info.rate_limit is None - - # Test wrong value type - info = render_to_info(hass, "{{ label_devices(42) }}") - assert_result_info(info, []) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ 42 | label_devices }}") - assert_result_info(info, []) - assert info.rate_limit is None - - # Create a fake config entry with a device - config_entry = MockConfigEntry(domain="light") - config_entry.add_to_hass(hass) - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - - # Add a label to it - label = label_registry.async_create("Romantic Lights") - device_registry.async_update_device(device_entry.id, labels=[label.label_id]) - - # Get the devices from a label by its ID - info = render_to_info(hass, f"{{{{ label_devices('{label.label_id}') }}}}") - assert_result_info(info, [device_entry.id]) - assert info.rate_limit is None - - info = render_to_info(hass, f"{{{{ '{label.label_id}' | label_devices }}}}") - assert_result_info(info, [device_entry.id]) - assert info.rate_limit is None - - # Get the devices from a label by its name - info = render_to_info(hass, f"{{{{ label_devices('{label.name}') }}}}") - assert_result_info(info, [device_entry.id]) - assert info.rate_limit is None - - info = render_to_info(hass, f"{{{{ '{label.name}' | label_devices }}}}") - assert_result_info(info, [device_entry.id]) - assert info.rate_limit is None - - -async def test_label_areas( - hass: HomeAssistant, - area_registry: ar.AreaRegistry, - label_registry: lr.LabelRegistry, -) -> None: - """Test label_areas function.""" - - # Test non existing area ID - info = render_to_info(hass, "{{ label_areas('deadbeef') }}") - assert_result_info(info, []) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ 'deadbeef' | label_areas }}") - assert_result_info(info, []) - assert info.rate_limit is None - - # Test wrong value type - info = render_to_info(hass, "{{ label_areas(42) }}") - assert_result_info(info, []) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ 42 | label_areas }}") - assert_result_info(info, []) - assert info.rate_limit is None - - # Create an area with an label - label = label_registry.async_create("Upstairs") - master_bedroom = area_registry.async_create( - "Master Bedroom", labels=[label.label_id] - ) - - # Get areas by label ID - info = render_to_info(hass, f"{{{{ label_areas('{label.label_id}') }}}}") - assert_result_info(info, [master_bedroom.id]) - assert info.rate_limit is None - - info = render_to_info(hass, f"{{{{ '{label.label_id}' | label_areas }}}}") - assert_result_info(info, [master_bedroom.id]) - assert info.rate_limit is None - - # Get areas by label name - info = render_to_info(hass, f"{{{{ label_areas('{label.name}') }}}}") - assert_result_info(info, [master_bedroom.id]) - assert info.rate_limit is None - - info = render_to_info(hass, f"{{{{ '{label.name}' | label_areas }}}}") - assert_result_info(info, [master_bedroom.id]) - assert info.rate_limit is None - - async def test_template_thread_safety_checks(hass: HomeAssistant) -> None: """Test template thread safety checks.""" hass.states.async_set("sensor.test", "23") diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index d91a53fffe914a..75fe9a91cab84d 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -1117,3 +1117,106 @@ def stop_listen(self) -> None: # Ensure the coordinator is released assert weak_ref() is None + + +@pytest.mark.parametrize( + ("exc", "expected_exception", "message"), + [ + *KNOWN_ERRORS, + (Exception(), Exception, "Unknown exception"), + ( + update_coordinator.UpdateFailed(retry_after=60), + update_coordinator.UpdateFailed, + "Error fetching test data", + ), + ], +) +@pytest.mark.parametrize( + "method", + ["update_method", "setup_method"], +) +async def test_update_failed_retry_after( + hass: HomeAssistant, + exc: Exception, + expected_exception: type[Exception], + message: str, + method: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test async_config_entry_first_refresh raises ConfigEntryNotReady on failure. + + Verify we do not log the exception since raising ConfigEntryNotReady + will be caught by config_entries.async_setup which will log it with + a decreasing level of logging once the first message is logged. + """ + entry = MockConfigEntry() + entry.mock_state( + hass, + config_entries.ConfigEntryState.SETUP_IN_PROGRESS, + ) + crd = get_crd(hass, DEFAULT_UPDATE_INTERVAL, entry) + setattr(crd, method, AsyncMock(side_effect=exc)) + + with pytest.raises(ConfigEntryNotReady): + await crd.async_config_entry_first_refresh() + + assert crd.last_update_success is False + assert isinstance(crd.last_exception, expected_exception) + assert message not in caplog.text + + # Only to check the retry_after wasn't hit + assert crd._retry_after is None + + +@pytest.mark.parametrize( + ("exc", "expected_exception", "message"), + [ + ( + update_coordinator.UpdateFailed(retry_after=60), + update_coordinator.UpdateFailed, + "Error fetching test data", + ), + ], +) +async def test_refresh_known_errors_retry_after( + exc: update_coordinator.UpdateFailed, + expected_exception: type[Exception], + message: str, + crd: update_coordinator.DataUpdateCoordinator[int], + caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, +) -> None: + """Test raising known errors, this time with retry_after.""" + unsub = crd.async_add_listener(lambda: None) + + crd.update_method = AsyncMock(side_effect=exc) + + with ( + patch.object(hass.loop, "time", return_value=1_000.0), + patch.object(hass.loop, "call_at") as mock_call_at, + ): + await crd.async_refresh() + + assert crd.data is None + assert crd.last_update_success is False + assert isinstance(crd.last_exception, expected_exception) + assert message in caplog.text + + when = mock_call_at.call_args[0][0] + + expected = 1_000.0 + crd._microsecond + exc.retry_after + assert abs(when - expected) < 0.005, (when, expected) + + assert crd._retry_after is None + + # Next schedule should fall back to regular update_interval + mock_call_at.reset_mock() + crd._schedule_refresh() + when2 = mock_call_at.call_args[0][0] + expected_cancelled = ( + 1_000.0 + crd._microsecond + crd.update_interval.total_seconds() + ) + assert abs(when2 - expected_cancelled) < 0.005, (when2, expected_cancelled) + + unsub() + crd._unschedule_refresh()