diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index e5d830d9d7d8a..895a19aa1a616 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -5,6 +5,7 @@ from functools import partial import json import logging +import re from typing import Any import anthropic @@ -283,7 +284,11 @@ async def async_step_advanced( vol.Optional( CONF_CHAT_MODEL, default=RECOMMENDED_CHAT_MODEL, - ): str, + ): SelectSelector( + SelectSelectorConfig( + options=await self._get_model_list(), custom_value=True + ) + ), vol.Optional( CONF_MAX_TOKENS, default=RECOMMENDED_MAX_TOKENS, @@ -394,6 +399,39 @@ async def async_step_model( last_step=True, ) + async def _get_model_list(self) -> list[SelectOptionDict]: + """Get list of available models.""" + try: + client = await self.hass.async_add_executor_job( + partial( + anthropic.AsyncAnthropic, + api_key=self._get_entry().data[CONF_API_KEY], + ) + ) + models = (await client.models.list()).data + except anthropic.AnthropicError: + models = [] + _LOGGER.debug("Available models: %s", models) + model_options: list[SelectOptionDict] = [] + short_form = re.compile(r"[^\d]-\d$") + for model_info in models: + # Resolve alias from versioned model name: + model_alias = ( + model_info.id[:-9] + if model_info.id + not in ("claude-3-haiku-20240307", "claude-3-opus-20240229") + else model_info.id + ) + if short_form.search(model_alias): + model_alias += "-0" + model_options.append( + SelectOptionDict( + label=model_info.display_name, + value=model_alias, + ) + ) + return model_options + async def _get_location_data(self) -> dict[str, str]: """Get approximate location data of the user.""" location_data: dict[str, str] = {} diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index eac8ddcf713a8..3246eaa6e7453 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.45.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index 4a73bf779e04f..742c3042864e6 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -7,7 +7,7 @@ "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", - "requirements": ["async-upnp-client==0.45.0"], + "requirements": ["async-upnp-client==0.46.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index bf1892eccea29..d868c88679c4a 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -338,6 +338,13 @@ async def async_step_model( options.pop(CONF_CODE_INTERPRETER) if model.startswith(("o", "gpt-5")) and not model.startswith("gpt-5-pro"): + if model.startswith("gpt-5.1"): + reasoning_options = ["none", "low", "medium", "high"] + elif model.startswith("gpt-5"): + reasoning_options = ["minimal", "low", "medium", "high"] + else: + reasoning_options = ["low", "medium", "high"] + step_schema.update( { vol.Optional( @@ -345,9 +352,7 @@ async def async_step_model( default=RECOMMENDED_REASONING_EFFORT, ): SelectSelector( SelectSelectorConfig( - options=["low", "medium", "high"] - if model.startswith("o") - else ["minimal", "low", "medium", "high"], + options=reasoning_options, translation_key=CONF_REASONING_EFFORT, mode=SelectSelectorMode.DROPDOWN, ) diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 1a54b5b6e5020..a3910e86d8b17 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -510,6 +510,9 @@ async def _async_handle_chat_log( "verbosity": options.get(CONF_VERBOSITY, RECOMMENDED_VERBOSITY) } + if model_args["model"].startswith("gpt-5.1"): + model_args["prompt_cache_retention"] = "24h" + tools: list[ToolParam] = [] if chat_log.llm_api: tools = [ diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 1b3dced1e440d..f107b4d540523 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -146,7 +146,8 @@ "high": "[%key:common::state::high%]", "low": "[%key:common::state::low%]", "medium": "[%key:common::state::medium%]", - "minimal": "Minimal" + "minimal": "Minimal", + "none": "None" } }, "search_context_size": { diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index cce90bc2c687d..b3cc5fe0263ce 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -40,7 +40,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.7.2", "wakeonlan==3.1.0", - "async-upnp-client==0.45.0" + "async-upnp-client==0.46.0" ], "ssdp": [ { diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 2471e45b4e06a..6ae7d8275da75 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.45.0"] + "requirements": ["async-upnp-client==0.46.0"] } diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 825c5774c1df5..9211356a27ad8 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.45.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/uptime_kuma/manifest.json b/homeassistant/components/uptime_kuma/manifest.json index 6a19784c4894c..9376b09e05fcc 100644 --- a/homeassistant/components/uptime_kuma/manifest.json +++ b/homeassistant/components/uptime_kuma/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["pythonkuma"], "quality_scale": "platinum", - "requirements": ["pythonkuma==0.3.1"] + "requirements": ["pythonkuma==0.3.2"] } diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 585a0090a57f2..e09e176e46ef7 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -16,7 +16,7 @@ }, "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], - "requirements": ["yeelight==0.7.16", "async-upnp-client==0.45.0"], + "requirements": ["yeelight==0.7.16", "async-upnp-client==0.46.0"], "zeroconf": [ { "name": "yeelink-*", diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index f45a4b553262e..7b61f56c85e68 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -1166,13 +1166,6 @@ def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]: return list(found.values()) -def device_entities(hass: HomeAssistant, _device_id: str) -> Iterable[str]: - """Get entity ids for entities tied to a device.""" - entity_reg = er.async_get(hass) - entries = er.async_entries_for_device(entity_reg, _device_id) - return [entry.entity_id for entry in entries] - - def integration_entities(hass: HomeAssistant, entry_name: str) -> Iterable[str]: """Get entity ids for entities tied to an integration/domain. @@ -1214,65 +1207,6 @@ def config_entry_id(hass: HomeAssistant, entity_id: str) -> str | None: return None -def device_id(hass: HomeAssistant, entity_id_or_device_name: str) -> str | None: - """Get a device ID from an entity ID or device name.""" - entity_reg = er.async_get(hass) - entity = entity_reg.async_get(entity_id_or_device_name) - if entity is not None: - return entity.device_id - - dev_reg = dr.async_get(hass) - return next( - ( - device_id - for device_id, device in dev_reg.devices.items() - if (name := device.name_by_user or device.name) - and (str(entity_id_or_device_name) == name) - ), - None, - ) - - -def device_name(hass: HomeAssistant, lookup_value: str) -> str | None: - """Get the device name from an device id, or entity id.""" - device_reg = dr.async_get(hass) - if device := device_reg.async_get(lookup_value): - return device.name_by_user or device.name - - 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 - - try: - cv.entity_id(lookup_value) - except vol.Invalid: - pass - else: - if entity := ent_reg.async_get(lookup_value): - if entity.device_id and (device := device_reg.async_get(entity.device_id)): - return device.name_by_user or device.name - - return None - - -def device_attr(hass: HomeAssistant, device_or_entity_id: str, attr_name: str) -> Any: - """Get the device specific attribute.""" - device_reg = dr.async_get(hass) - if not isinstance(device_or_entity_id, str): - raise TemplateError("Must provide a device or entity ID") - device = None - if ( - "." in device_or_entity_id - and (_device_id := device_id(hass, device_or_entity_id)) is not None - ): - device = device_reg.async_get(_device_id) - elif "." not in device_or_entity_id: - device = device_reg.async_get(device_or_entity_id) - if device is None or not hasattr(device, attr_name): - return None - return getattr(device, attr_name) - - def config_entry_attr( hass: HomeAssistant, config_entry_id_: str, attr_name: str ) -> Any: @@ -1291,13 +1225,6 @@ def config_entry_attr( return getattr(config_entry, attr_name) -def is_device_attr( - hass: HomeAssistant, device_or_entity_id: str, attr_name: str, attr_value: Any -) -> bool: - """Test if a device's attribute is a specific value.""" - return bool(device_attr(hass, device_or_entity_id, attr_name) == attr_value) - - def issues(hass: HomeAssistant) -> dict[tuple[str, str], dict[str, Any]]: """Return all open issues.""" current_issues = ir.async_get(hass).issues @@ -2260,6 +2187,7 @@ def __init__( "homeassistant.helpers.template.extensions.CollectionExtension" ) self.add_extension("homeassistant.helpers.template.extensions.CryptoExtension") + self.add_extension("homeassistant.helpers.template.extensions.DeviceExtension") self.add_extension("homeassistant.helpers.template.extensions.FloorExtension") self.add_extension("homeassistant.helpers.template.extensions.LabelExtension") self.add_extension("homeassistant.helpers.template.extensions.MathExtension") @@ -2377,23 +2305,6 @@ def wrapper(_: Any, *args: _P.args, **kwargs: _P.kwargs) -> _R: self.globals["config_entry_id"] = hassfunction(config_entry_id) self.filters["config_entry_id"] = self.globals["config_entry_id"] - # Device extensions - - self.globals["device_name"] = hassfunction(device_name) - self.filters["device_name"] = self.globals["device_name"] - - self.globals["device_attr"] = hassfunction(device_attr) - self.filters["device_attr"] = self.globals["device_attr"] - - self.globals["device_entities"] = hassfunction(device_entities) - self.filters["device_entities"] = self.globals["device_entities"] - - self.globals["is_device_attr"] = hassfunction(is_device_attr) - self.tests["is_device_attr"] = hassfunction(is_device_attr, pass_eval_context) - - self.globals["device_id"] = hassfunction(device_id) - self.filters["device_id"] = self.globals["device_id"] - # Issue extensions self.globals["issues"] = hassfunction(issues) @@ -2415,12 +2326,9 @@ def warn_unsupported(*args: Any, **kwargs: Any) -> NoReturn: "area_id", "area_name", "closest", - "device_attr", - "device_id", "distance", "expand", "has_value", - "is_device_attr", "is_hidden_entity", "is_state_attr", "is_state", @@ -2438,7 +2346,6 @@ def warn_unsupported(*args: Any, **kwargs: Any) -> NoReturn: "area_id", "area_name", "closest", - "device_id", "expand", "has_value", ] diff --git a/homeassistant/helpers/template/extensions/__init__.py b/homeassistant/helpers/template/extensions/__init__.py index 96cc11ccab1a0..02be2c1f35924 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 .devices import DeviceExtension from .floors import FloorExtension from .labels import LabelExtension from .math import MathExtension @@ -13,6 +14,7 @@ "Base64Extension", "CollectionExtension", "CryptoExtension", + "DeviceExtension", "FloorExtension", "LabelExtension", "MathExtension", diff --git a/homeassistant/helpers/template/extensions/devices.py b/homeassistant/helpers/template/extensions/devices.py new file mode 100644 index 0000000000000..aeef013f18a18 --- /dev/null +++ b/homeassistant/helpers/template/extensions/devices.py @@ -0,0 +1,139 @@ +"""Device 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.exceptions import TemplateError +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) + +from .base import BaseTemplateExtension, TemplateFunction + +if TYPE_CHECKING: + from homeassistant.helpers.template import TemplateEnvironment + + +class DeviceExtension(BaseTemplateExtension): + """Extension for device-related template functions.""" + + def __init__(self, environment: TemplateEnvironment) -> None: + """Initialize the device extension.""" + super().__init__( + environment, + functions=[ + TemplateFunction( + "device_entities", + self.device_entities, + as_global=True, + as_filter=True, + requires_hass=True, + ), + TemplateFunction( + "device_id", + self.device_id, + as_global=True, + as_filter=True, + requires_hass=True, + limited_ok=False, + ), + TemplateFunction( + "device_name", + self.device_name, + as_global=True, + as_filter=True, + requires_hass=True, + limited_ok=False, + ), + TemplateFunction( + "device_attr", + self.device_attr, + as_global=True, + as_filter=True, + requires_hass=True, + limited_ok=False, + ), + TemplateFunction( + "is_device_attr", + self.is_device_attr, + as_global=True, + as_test=True, + requires_hass=True, + limited_ok=False, + ), + ], + ) + + def device_entities(self, _device_id: str) -> Iterable[str]: + """Get entity ids for entities tied to a device.""" + entity_reg = er.async_get(self.hass) + entries = er.async_entries_for_device(entity_reg, _device_id) + return [entry.entity_id for entry in entries] + + def device_id(self, entity_id_or_device_name: str) -> str | None: + """Get a device ID from an entity ID or device name.""" + entity_reg = er.async_get(self.hass) + entity = entity_reg.async_get(entity_id_or_device_name) + if entity is not None: + return entity.device_id + + dev_reg = dr.async_get(self.hass) + return next( + ( + device_id + for device_id, device in dev_reg.devices.items() + if (name := device.name_by_user or device.name) + and (str(entity_id_or_device_name) == name) + ), + None, + ) + + def device_name(self, lookup_value: str) -> str | None: + """Get the device name from an device id, or entity id.""" + device_reg = dr.async_get(self.hass) + if device := device_reg.async_get(lookup_value): + return device.name_by_user or device.name + + ent_reg = er.async_get(self.hass) + + try: + cv.entity_id(lookup_value) + except vol.Invalid: + pass + else: + if entity := ent_reg.async_get(lookup_value): + if entity.device_id and ( + device := device_reg.async_get(entity.device_id) + ): + return device.name_by_user or device.name + + return None + + def device_attr(self, device_or_entity_id: str, attr_name: str) -> Any: + """Get the device specific attribute.""" + device_reg = dr.async_get(self.hass) + if not isinstance(device_or_entity_id, str): + raise TemplateError("Must provide a device or entity ID") + device = None + if ( + "." in device_or_entity_id + and (_device_id := self.device_id(device_or_entity_id)) is not None + ): + device = device_reg.async_get(_device_id) + elif "." not in device_or_entity_id: + device = device_reg.async_get(device_or_entity_id) + if device is None or not hasattr(device, attr_name): + return None + return getattr(device, attr_name) + + def is_device_attr( + self, device_or_entity_id: str, attr_name: str, attr_value: Any + ) -> bool: + """Test if a device's attribute is a specific value.""" + return bool(self.device_attr(device_or_entity_id, attr_name) == attr_value) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bedced9a4d43a..b108e08b805dd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,7 +13,7 @@ aiozoneinfo==0.2.3 annotatedyaml==1.0.2 astral==2.2 async-interrupt==1.2.2 -async-upnp-client==0.45.0 +async-upnp-client==0.46.0 atomicwrites-homeassistant==1.4.1 attrs==25.4.0 audioop-lts==0.2.1 diff --git a/requirements_all.txt b/requirements_all.txt index a39c25c8a66fa..8940cc62a3a63 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -541,7 +541,7 @@ asusrouter==1.21.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.45.0 +async-upnp-client==0.46.0 # homeassistant.components.arve asyncarve==0.1.1 @@ -2570,7 +2570,7 @@ python-xbox==0.1.1 pythonegardia==1.0.52 # homeassistant.components.uptime_kuma -pythonkuma==0.3.1 +pythonkuma==0.3.2 # homeassistant.components.tile pytile==2024.12.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 79ac88818b19d..4426d0ddce723 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -505,7 +505,7 @@ asusrouter==1.21.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.45.0 +async-upnp-client==0.46.0 # homeassistant.components.arve asyncarve==0.1.1 @@ -2130,7 +2130,7 @@ python-telegram-bot[socks]==22.1 python-xbox==0.1.1 # homeassistant.components.uptime_kuma -pythonkuma==0.3.1 +pythonkuma==0.3.2 # homeassistant.components.tile pytile==2024.12.0 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index e30e5766b776c..7f51c3cc50164 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -130,9 +130,6 @@ "pbr": {"setuptools"} }, "delijn": {"pydelijn": {"async-timeout"}}, - "devialet": {"async-upnp-client": {"async-timeout"}}, - "dlna_dmr": {"async-upnp-client": {"async-timeout"}}, - "dlna_dms": {"async-upnp-client": {"async-timeout"}}, "efergy": { # https://github.com/tkdrob/pyefergy/issues/46 # pyefergy > codecov @@ -195,7 +192,6 @@ "lifx": {"aiolifx": {"async-timeout"}}, "linkplay": { "python-linkplay": {"async-timeout"}, - "async-upnp-client": {"async-timeout"}, }, "loqed": {"loqedapi": {"async-timeout"}}, "matter": {"python-matter-server": {"async-timeout"}}, @@ -221,7 +217,6 @@ "nibe_heatpump": {"nibe": {"async-timeout"}}, "norway_air": {"pymetno": {"async-timeout"}}, "opengarage": {"open-garage": {"async-timeout"}}, - "openhome": {"async-upnp-client": {"async-timeout"}}, "opensensemap": {"opensensemap-api": {"async-timeout"}}, "opnsense": { # https://github.com/mtreinish/pyopnsense/issues/27 @@ -236,12 +231,9 @@ }, "ring": {"ring-doorbell": {"async-timeout"}}, "rmvtransport": {"pyrmvtransport": {"async-timeout"}}, - "samsungtv": {"async-upnp-client": {"async-timeout"}}, "screenlogic": {"screenlogicpy": {"async-timeout"}}, "sense": {"sense-energy": {"async-timeout"}}, "slimproto": {"aioslimproto": {"async-timeout"}}, - "songpal": {"async-upnp-client": {"async-timeout"}}, - "ssdp": {"async-upnp-client": {"async-timeout"}}, "surepetcare": {"surepy": {"async-timeout"}}, "travisci": { # https://github.com/menegazzo/travispy seems to be unmaintained @@ -252,10 +244,8 @@ "travispy": {"pytest"}, }, "unifiprotect": {"uiprotect": {"async-timeout"}}, - "upnp": {"async-upnp-client": {"async-timeout"}}, "volkszaehler": {"volkszaehler": {"async-timeout"}}, "whirlpool": {"whirlpool-sixth-sense": {"async-timeout"}}, - "yeelight": {"async-upnp-client": {"async-timeout"}}, "zamg": {"zamg": {"async-timeout"}}, "zha": { # https://github.com/waveform80/colorzero/issues/9 diff --git a/tests/components/anthropic/conftest.py b/tests/components/anthropic/conftest.py index 334e9f94fae20..207e1d410e6ad 100644 --- a/tests/components/anthropic/conftest.py +++ b/tests/components/anthropic/conftest.py @@ -1,11 +1,14 @@ """Tests helpers.""" from collections.abc import AsyncGenerator, Generator, Iterable +import datetime from unittest.mock import AsyncMock, patch +from anthropic.pagination import AsyncPage from anthropic.types import ( Message, MessageDeltaUsage, + ModelInfo, RawContentBlockStartEvent, RawMessageDeltaEvent, RawMessageStartEvent, @@ -123,7 +126,72 @@ async def mock_init_component( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> AsyncGenerator[None]: """Initialize integration.""" - with patch("anthropic.resources.models.AsyncModels.retrieve"): + model_list = AsyncPage( + data=[ + ModelInfo( + id="claude-haiku-4-5-20251001", + created_at=datetime.datetime(2025, 10, 15, 0, 0, tzinfo=datetime.UTC), + display_name="Claude Haiku 4.5", + type="model", + ), + ModelInfo( + id="claude-sonnet-4-5-20250929", + created_at=datetime.datetime(2025, 9, 29, 0, 0, tzinfo=datetime.UTC), + display_name="Claude Sonnet 4.5", + type="model", + ), + ModelInfo( + id="claude-opus-4-1-20250805", + created_at=datetime.datetime(2025, 8, 5, 0, 0, tzinfo=datetime.UTC), + display_name="Claude Opus 4.1", + type="model", + ), + ModelInfo( + id="claude-opus-4-20250514", + created_at=datetime.datetime(2025, 5, 22, 0, 0, tzinfo=datetime.UTC), + display_name="Claude Opus 4", + type="model", + ), + ModelInfo( + id="claude-sonnet-4-20250514", + created_at=datetime.datetime(2025, 5, 22, 0, 0, tzinfo=datetime.UTC), + display_name="Claude Sonnet 4", + type="model", + ), + ModelInfo( + id="claude-3-7-sonnet-20250219", + created_at=datetime.datetime(2025, 2, 24, 0, 0, tzinfo=datetime.UTC), + display_name="Claude Sonnet 3.7", + type="model", + ), + ModelInfo( + id="claude-3-5-haiku-20241022", + created_at=datetime.datetime(2024, 10, 22, 0, 0, tzinfo=datetime.UTC), + display_name="Claude Haiku 3.5", + type="model", + ), + ModelInfo( + id="claude-3-haiku-20240307", + created_at=datetime.datetime(2024, 3, 7, 0, 0, tzinfo=datetime.UTC), + display_name="Claude Haiku 3", + type="model", + ), + ModelInfo( + id="claude-3-opus-20240229", + created_at=datetime.datetime(2024, 2, 29, 0, 0, tzinfo=datetime.UTC), + display_name="Claude Opus 3", + type="model", + ), + ] + ) + with ( + patch("anthropic.resources.models.AsyncModels.retrieve"), + patch( + "anthropic.resources.models.AsyncModels.list", + new_callable=AsyncMock, + return_value=model_list, + ), + ): assert await async_setup_component(hass, "anthropic", {}) await hass.async_block_till_done() yield diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index eb31963b254de..158302dc61cd6 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -339,6 +339,99 @@ async def test_subentry_web_search_user_location( } +async def test_model_list( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test fetching and processing the list of models.""" + subentry = next(iter(mock_config_entry.subentries.values())) + options_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + + # Configure initial step + options = await hass.config_entries.subentries.async_configure( + options_flow["flow_id"], + { + "prompt": "You are a helpful assistant", + "recommended": False, + }, + ) + assert options["type"] == FlowResultType.FORM + assert options["step_id"] == "advanced" + assert options["data_schema"].schema["chat_model"].config["options"] == [ + { + "label": "Claude Haiku 4.5", + "value": "claude-haiku-4-5", + }, + { + "label": "Claude Sonnet 4.5", + "value": "claude-sonnet-4-5", + }, + { + "label": "Claude Opus 4.1", + "value": "claude-opus-4-1", + }, + { + "label": "Claude Opus 4", + "value": "claude-opus-4-0", + }, + { + "label": "Claude Sonnet 4", + "value": "claude-sonnet-4-0", + }, + { + "label": "Claude Sonnet 3.7", + "value": "claude-3-7-sonnet", + }, + { + "label": "Claude Haiku 3.5", + "value": "claude-3-5-haiku", + }, + { + "label": "Claude Haiku 3", + "value": "claude-3-haiku-20240307", + }, + { + "label": "Claude Opus 3", + "value": "claude-3-opus-20240229", + }, + ] + + +async def test_model_list_error( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test exception handling during fetching the list of models.""" + subentry = next(iter(mock_config_entry.subentries.values())) + options_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + + # Configure initial step + with patch( + "homeassistant.components.anthropic.config_flow.anthropic.resources.models.AsyncModels.list", + new_callable=AsyncMock, + side_effect=InternalServerError( + message=None, + response=Response( + status_code=500, + request=Request(method="POST", url=URL()), + ), + body=None, + ), + ): + options = await hass.config_entries.subentries.async_configure( + options_flow["flow_id"], + { + "prompt": "You are a helpful assistant", + "recommended": False, + }, + ) + assert options["type"] == FlowResultType.FORM + assert options["step_id"] == "advanced" + assert options["data_schema"].schema["chat_model"].config["options"] == [] + + @pytest.mark.parametrize( ("current_options", "new_options", "expected_options"), [ diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 885e25936d3bb..de895afc96a7c 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -235,6 +235,56 @@ async def test_subentry_unsupported_model( assert subentry_flow["errors"] == {"chat_model": "model_not_supported"} +@pytest.mark.parametrize( + ("model", "reasoning_effort_options"), + [ + ("o4-mini", ["low", "medium", "high"]), + ("gpt-5", ["minimal", "low", "medium", "high"]), + ("gpt-5.1", ["none", "low", "medium", "high"]), + ], +) +async def test_subentry_reasoning_effort_list( + hass: HomeAssistant, + mock_config_entry, + mock_init_component, + model, + reasoning_effort_options, +) -> None: + """Test the list reasoning effort options.""" + subentry = next(iter(mock_config_entry.subentries.values())) + subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "init" + + # Configure initial step + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: ["assist"], + }, + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "advanced" + + # Configure advanced step + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_CHAT_MODEL: model, + }, + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "model" + assert ( + subentry_flow["data_schema"].schema[CONF_REASONING_EFFORT].config["options"] + == reasoning_effort_options + ) + + async def test_subentry_websearch_unsupported_reasoning_effort( hass: HomeAssistant, mock_config_entry, mock_init_component ) -> None: diff --git a/tests/helpers/template/extensions/test_devices.py b/tests/helpers/template/extensions/test_devices.py new file mode 100644 index 0000000000000..e67e5255b37f6 --- /dev/null +++ b/tests/helpers/template/extensions/test_devices.py @@ -0,0 +1,329 @@ +"""Test device template functions.""" + +from __future__ import annotations + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.template import TemplateError + +from tests.common import MockConfigEntry +from tests.helpers.template.helpers import assert_result_info, render_to_info + + +async def test_device_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test device_entities function.""" + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + + # Test non existing device ids + info = render_to_info(hass, "{{ device_entities('abc123') }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ device_entities(56) }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test device without entities + 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")}, + ) + info = render_to_info(hass, f"{{{{ device_entities('{device_entry.id}') }}}}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test device with single entity, which has no state + entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry, + device_id=device_entry.id, + ) + info = render_to_info(hass, f"{{{{ device_entities('{device_entry.id}') }}}}") + assert_result_info(info, ["light.hue_5678"], []) + assert info.rate_limit is None + info = render_to_info( + hass, + ( + f"{{{{ device_entities('{device_entry.id}') | expand " + "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" + ), + ) + assert_result_info(info, "", ["light.hue_5678"]) + assert info.rate_limit is None + + # Test device with single entity, with state + hass.states.async_set("light.hue_5678", "happy") + info = render_to_info( + hass, + ( + f"{{{{ device_entities('{device_entry.id}') | expand " + "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" + ), + ) + assert_result_info(info, "light.hue_5678", ["light.hue_5678"]) + assert info.rate_limit is None + + # Test device with multiple entities, which have a state + entity_registry.async_get_or_create( + "light", + "hue", + "ABCD", + config_entry=config_entry, + device_id=device_entry.id, + ) + hass.states.async_set("light.hue_abcd", "camper") + info = render_to_info(hass, f"{{{{ device_entities('{device_entry.id}') }}}}") + assert_result_info(info, ["light.hue_5678", "light.hue_abcd"], []) + assert info.rate_limit is None + info = render_to_info( + hass, + ( + f"{{{{ device_entities('{device_entry.id}') | expand " + "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" + ), + ) + assert_result_info( + info, "light.hue_5678, light.hue_abcd", ["light.hue_5678", "light.hue_abcd"] + ) + assert info.rate_limit is None + + +async def test_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test device_id function.""" + 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")}, + model="test", + name="test", + ) + entity_entry = entity_registry.async_get_or_create( + "sensor", "test", "test", suggested_object_id="test", device_id=device_entry.id + ) + entity_entry_no_device = entity_registry.async_get_or_create( + "sensor", "test", "test_no_device", suggested_object_id="test" + ) + + info = render_to_info(hass, "{{ 'sensor.fail' | device_id }}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 56 | device_id }}") + assert_result_info(info, None) + + info = render_to_info(hass, "{{ 'not_a_real_entity_id' | device_id }}") + assert_result_info(info, None) + + info = render_to_info( + hass, f"{{{{ device_id('{entity_entry_no_device.entity_id}') }}}}" + ) + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ device_id('{entity_entry.entity_id}') }}}}") + assert_result_info(info, device_entry.id) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ device_id('test') }}") + assert_result_info(info, device_entry.id) + assert info.rate_limit is None + + +async def test_device_name( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test device_name function.""" + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + + # Test non existing entity id + info = render_to_info(hass, "{{ device_name('sensor.fake') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test non existing device id + info = render_to_info(hass, "{{ device_name('1234567890') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ device_name(56) }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test device with single entity + 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")}, + name="A light", + ) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry, + device_id=device_entry.id, + ) + info = render_to_info(hass, f"{{{{ device_name('{device_entry.id}') }}}}") + assert_result_info(info, device_entry.name) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ device_name('{entity_entry.entity_id}') }}}}") + assert_result_info(info, device_entry.name) + assert info.rate_limit is None + + # Test device after renaming + device_entry = device_registry.async_update_device( + device_entry.id, + name_by_user="My light", + ) + + info = render_to_info(hass, f"{{{{ device_name('{device_entry.id}') }}}}") + assert_result_info(info, device_entry.name_by_user) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ device_name('{entity_entry.entity_id}') }}}}") + assert_result_info(info, device_entry.name_by_user) + assert info.rate_limit is None + + +async def test_device_attr( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test device_attr and is_device_attr functions.""" + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + + # Test non existing device ids (device_attr) + info = render_to_info(hass, "{{ device_attr('abc123', 'id') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ device_attr(56, 'id') }}") + with pytest.raises(TemplateError): + assert_result_info(info, None) + + # Test non existing device ids (is_device_attr) + info = render_to_info(hass, "{{ is_device_attr('abc123', 'id', 'test') }}") + assert_result_info(info, False) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ is_device_attr(56, 'id', 'test') }}") + with pytest.raises(TemplateError): + assert_result_info(info, False) + + # Test non existing entity id (device_attr) + info = render_to_info(hass, "{{ device_attr('entity.test', 'id') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test non existing entity id (is_device_attr) + info = render_to_info(hass, "{{ is_device_attr('entity.test', 'id', 'test') }}") + assert_result_info(info, False) + assert info.rate_limit is None + + 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")}, + model="test", + ) + entity_entry = entity_registry.async_get_or_create( + "sensor", "test", "test", suggested_object_id="test", device_id=device_entry.id + ) + + # Test non existent device attribute (device_attr) + info = render_to_info( + hass, f"{{{{ device_attr('{device_entry.id}', 'invalid_attr') }}}}" + ) + assert_result_info(info, None) + assert info.rate_limit is None + + # Test non existent device attribute (is_device_attr) + info = render_to_info( + hass, f"{{{{ is_device_attr('{device_entry.id}', 'invalid_attr', 'test') }}}}" + ) + assert_result_info(info, False) + assert info.rate_limit is None + + # Test None device attribute (device_attr) + info = render_to_info( + hass, f"{{{{ device_attr('{device_entry.id}', 'manufacturer') }}}}" + ) + assert_result_info(info, None) + assert info.rate_limit is None + + # Test None device attribute mismatch (is_device_attr) + info = render_to_info( + hass, f"{{{{ is_device_attr('{device_entry.id}', 'manufacturer', 'test') }}}}" + ) + assert_result_info(info, False) + assert info.rate_limit is None + + # Test None device attribute match (is_device_attr) + info = render_to_info( + hass, f"{{{{ is_device_attr('{device_entry.id}', 'manufacturer', None) }}}}" + ) + assert_result_info(info, True) + assert info.rate_limit is None + + # Test valid device attribute match (device_attr) + info = render_to_info(hass, f"{{{{ device_attr('{device_entry.id}', 'model') }}}}") + assert_result_info(info, "test") + assert info.rate_limit is None + + # Test valid device attribute match (device_attr) + info = render_to_info( + hass, f"{{{{ device_attr('{entity_entry.entity_id}', 'model') }}}}" + ) + assert_result_info(info, "test") + assert info.rate_limit is None + + # Test valid device attribute mismatch (is_device_attr) + info = render_to_info( + hass, f"{{{{ is_device_attr('{device_entry.id}', 'model', 'fail') }}}}" + ) + assert_result_info(info, False) + assert info.rate_limit is None + + # Test valid device attribute match (is_device_attr) + info = render_to_info( + hass, f"{{{{ is_device_attr('{device_entry.id}', 'model', 'test') }}}}" + ) + assert_result_info(info, True) + assert info.rate_limit is None + + # Test filter syntax (device_attr) + info = render_to_info( + hass, f"{{{{ '{entity_entry.entity_id}' | device_attr('model') }}}}" + ) + assert_result_info(info, "test") + assert info.rate_limit is None + + # Test test syntax (is_device_attr) + info = render_to_info( + hass, + ( + f"{{{{ ['{device_entry.id}'] | select('is_device_attr', 'model', 'test') " + "| list }}" + ), + ) + assert_result_info(info, [device_entry.id]) + assert info.rate_limit is None diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index 7da8f9f0abb37..faf1481faffb2 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -2392,91 +2392,6 @@ async def test_expand(hass: HomeAssistant) -> None: ) -async def test_device_entities( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test device_entities function.""" - config_entry = MockConfigEntry(domain="light") - config_entry.add_to_hass(hass) - - # Test non existing device ids - info = render_to_info(hass, "{{ device_entities('abc123') }}") - assert_result_info(info, []) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ device_entities(56) }}") - assert_result_info(info, []) - assert info.rate_limit is None - - # Test device without entities - 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")}, - ) - info = render_to_info(hass, f"{{{{ device_entities('{device_entry.id}') }}}}") - assert_result_info(info, []) - assert info.rate_limit is None - - # Test device with single entity, which has no state - entity_registry.async_get_or_create( - "light", - "hue", - "5678", - config_entry=config_entry, - device_id=device_entry.id, - ) - info = render_to_info(hass, f"{{{{ device_entities('{device_entry.id}') }}}}") - assert_result_info(info, ["light.hue_5678"], []) - assert info.rate_limit is None - info = render_to_info( - hass, - ( - f"{{{{ device_entities('{device_entry.id}') | expand " - "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" - ), - ) - assert_result_info(info, "", ["light.hue_5678"]) - assert info.rate_limit is None - - # Test device with single entity, with state - hass.states.async_set("light.hue_5678", "happy") - info = render_to_info( - hass, - ( - f"{{{{ device_entities('{device_entry.id}') | expand " - "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" - ), - ) - assert_result_info(info, "light.hue_5678", ["light.hue_5678"]) - assert info.rate_limit is None - - # Test device with multiple entities, which have a state - entity_registry.async_get_or_create( - "light", - "hue", - "ABCD", - config_entry=config_entry, - device_id=device_entry.id, - ) - hass.states.async_set("light.hue_abcd", "camper") - info = render_to_info(hass, f"{{{{ device_entities('{device_entry.id}') }}}}") - assert_result_info(info, ["light.hue_5678", "light.hue_abcd"], []) - assert info.rate_limit is None - info = render_to_info( - hass, - ( - f"{{{{ device_entities('{device_entry.id}') | expand " - "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" - ), - ) - assert_result_info( - info, "light.hue_5678, light.hue_abcd", ["light.hue_5678", "light.hue_abcd"] - ) - assert info.rate_limit is None - - async def test_integration_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: @@ -2569,238 +2484,6 @@ async def test_config_entry_id( assert info.rate_limit is None -async def test_device_id( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test device_id function.""" - 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")}, - model="test", - name="test", - ) - entity_entry = entity_registry.async_get_or_create( - "sensor", "test", "test", suggested_object_id="test", device_id=device_entry.id - ) - entity_entry_no_device = entity_registry.async_get_or_create( - "sensor", "test", "test_no_device", suggested_object_id="test" - ) - - info = render_to_info(hass, "{{ 'sensor.fail' | device_id }}") - assert_result_info(info, None) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ 56 | device_id }}") - assert_result_info(info, None) - - info = render_to_info(hass, "{{ 'not_a_real_entity_id' | device_id }}") - assert_result_info(info, None) - - info = render_to_info( - hass, f"{{{{ device_id('{entity_entry_no_device.entity_id}') }}}}" - ) - assert_result_info(info, None) - assert info.rate_limit is None - - info = render_to_info(hass, f"{{{{ device_id('{entity_entry.entity_id}') }}}}") - assert_result_info(info, device_entry.id) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ device_id('test') }}") - assert_result_info(info, device_entry.id) - assert info.rate_limit is None - - -async def test_device_name( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test device_name function.""" - config_entry = MockConfigEntry(domain="light") - config_entry.add_to_hass(hass) - - # Test non existing entity id - info = render_to_info(hass, "{{ device_name('sensor.fake') }}") - assert_result_info(info, None) - assert info.rate_limit is None - - # Test non existing device id - info = render_to_info(hass, "{{ device_name('1234567890') }}") - assert_result_info(info, None) - assert info.rate_limit is None - - # Test wrong value type - info = render_to_info(hass, "{{ device_name(56) }}") - assert_result_info(info, None) - assert info.rate_limit is None - - # Test device with single entity - 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")}, - name="A light", - ) - entity_entry = entity_registry.async_get_or_create( - "light", - "hue", - "5678", - config_entry=config_entry, - device_id=device_entry.id, - ) - info = render_to_info(hass, f"{{{{ device_name('{device_entry.id}') }}}}") - assert_result_info(info, device_entry.name) - assert info.rate_limit is None - - info = render_to_info(hass, f"{{{{ device_name('{entity_entry.entity_id}') }}}}") - assert_result_info(info, device_entry.name) - assert info.rate_limit is None - - # Test device after renaming - device_entry = device_registry.async_update_device( - device_entry.id, - name_by_user="My light", - ) - - info = render_to_info(hass, f"{{{{ device_name('{device_entry.id}') }}}}") - assert_result_info(info, device_entry.name_by_user) - assert info.rate_limit is None - - info = render_to_info(hass, f"{{{{ device_name('{entity_entry.entity_id}') }}}}") - assert_result_info(info, device_entry.name_by_user) - assert info.rate_limit is None - - -async def test_device_attr( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test device_attr and is_device_attr functions.""" - config_entry = MockConfigEntry(domain="light") - config_entry.add_to_hass(hass) - - # Test non existing device ids (device_attr) - info = render_to_info(hass, "{{ device_attr('abc123', 'id') }}") - assert_result_info(info, None) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ device_attr(56, 'id') }}") - with pytest.raises(TemplateError): - assert_result_info(info, None) - - # Test non existing device ids (is_device_attr) - info = render_to_info(hass, "{{ is_device_attr('abc123', 'id', 'test') }}") - assert_result_info(info, False) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ is_device_attr(56, 'id', 'test') }}") - with pytest.raises(TemplateError): - assert_result_info(info, False) - - # Test non existing entity id (device_attr) - info = render_to_info(hass, "{{ device_attr('entity.test', 'id') }}") - assert_result_info(info, None) - assert info.rate_limit is None - - # Test non existing entity id (is_device_attr) - info = render_to_info(hass, "{{ is_device_attr('entity.test', 'id', 'test') }}") - assert_result_info(info, False) - assert info.rate_limit is None - - 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")}, - model="test", - ) - entity_entry = entity_registry.async_get_or_create( - "sensor", "test", "test", suggested_object_id="test", device_id=device_entry.id - ) - - # Test non existent device attribute (device_attr) - info = render_to_info( - hass, f"{{{{ device_attr('{device_entry.id}', 'invalid_attr') }}}}" - ) - assert_result_info(info, None) - assert info.rate_limit is None - - # Test non existent device attribute (is_device_attr) - info = render_to_info( - hass, f"{{{{ is_device_attr('{device_entry.id}', 'invalid_attr', 'test') }}}}" - ) - assert_result_info(info, False) - assert info.rate_limit is None - - # Test None device attribute (device_attr) - info = render_to_info( - hass, f"{{{{ device_attr('{device_entry.id}', 'manufacturer') }}}}" - ) - assert_result_info(info, None) - assert info.rate_limit is None - - # Test None device attribute mismatch (is_device_attr) - info = render_to_info( - hass, f"{{{{ is_device_attr('{device_entry.id}', 'manufacturer', 'test') }}}}" - ) - assert_result_info(info, False) - assert info.rate_limit is None - - # Test None device attribute match (is_device_attr) - info = render_to_info( - hass, f"{{{{ is_device_attr('{device_entry.id}', 'manufacturer', None) }}}}" - ) - assert_result_info(info, True) - assert info.rate_limit is None - - # Test valid device attribute match (device_attr) - info = render_to_info(hass, f"{{{{ device_attr('{device_entry.id}', 'model') }}}}") - assert_result_info(info, "test") - assert info.rate_limit is None - - # Test valid device attribute match (device_attr) - info = render_to_info( - hass, f"{{{{ device_attr('{entity_entry.entity_id}', 'model') }}}}" - ) - assert_result_info(info, "test") - assert info.rate_limit is None - - # Test valid device attribute mismatch (is_device_attr) - info = render_to_info( - hass, f"{{{{ is_device_attr('{device_entry.id}', 'model', 'fail') }}}}" - ) - assert_result_info(info, False) - assert info.rate_limit is None - - # Test valid device attribute match (is_device_attr) - info = render_to_info( - hass, f"{{{{ is_device_attr('{device_entry.id}', 'model', 'test') }}}}" - ) - assert_result_info(info, True) - assert info.rate_limit is None - - # Test filter syntax (device_attr) - info = render_to_info( - hass, f"{{{{ '{entity_entry.entity_id}' | device_attr('model') }}}}" - ) - assert_result_info(info, "test") - assert info.rate_limit is None - - # Test test syntax (is_device_attr) - info = render_to_info( - hass, - ( - f"{{{{ ['{device_entry.id}'] | select('is_device_attr', 'model', 'test') " - "| list }}" - ), - ) - assert_result_info(info, [device_entry.id]) - assert info.rate_limit is None - - async def test_config_entry_attr(hass: HomeAssistant) -> None: """Test config entry attr.""" info = {