diff --git a/.strict-typing b/.strict-typing index 62796327123804..36da0a0b4bb9fc 100644 --- a/.strict-typing +++ b/.strict-typing @@ -595,6 +595,7 @@ homeassistant.components.timer.* homeassistant.components.tod.* homeassistant.components.todo.* homeassistant.components.tolo.* +homeassistant.components.tonewinner.* homeassistant.components.tplink.* homeassistant.components.tplink_omada.* homeassistant.components.trace.* diff --git a/CODEOWNERS b/CODEOWNERS index 63bcae0502c127..1f43227dcc4e20 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -839,7 +839,7 @@ CLAUDE.md @home-assistant/core /tests/components/hypontech/ @jcisio /homeassistant/components/ialarm/ @RyuzakiKK /tests/components/ialarm/ @RyuzakiKK -/homeassistant/components/iammeter/ @lewei50 +/homeassistant/components/iammeter/ @lewei50 @IAMMETER @miwyf /homeassistant/components/iaqualink/ @flz /tests/components/iaqualink/ @flz /homeassistant/components/icloud/ @Quentame @nzapponi @@ -1882,6 +1882,8 @@ CLAUDE.md @home-assistant/core /tests/components/tolo/ @MatthiasLohr /homeassistant/components/tomorrowio/ @raman325 @lymanepp /tests/components/tomorrowio/ @raman325 @lymanepp +/homeassistant/components/tonewinner/ @emma-sg +/tests/components/tonewinner/ @emma-sg /homeassistant/components/totalconnect/ @austinmroczek /tests/components/totalconnect/ @austinmroczek /homeassistant/components/touchline/ @mnordseth diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index d11c4a3c5be5b6..06ac87ab992769 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -933,9 +933,9 @@ async def _get_model_args( # noqa: C901 options: dict[str, Any] = DEFAULT | self.subentry.data preloaded_tools = [ - "HassTurnOn", - "HassTurnOff", - "GetLiveContext", + "intent__HassTurnOn", + "intent__HassTurnOff", + "homeassistant__GetLiveContext", "code_execution", "web_search", "web_fetch", diff --git a/homeassistant/components/assist_satellite/llm.py b/homeassistant/components/assist_satellite/llm.py index 6590875fe4bc01..fe5e631eafc173 100644 --- a/homeassistant/components/assist_satellite/llm.py +++ b/homeassistant/components/assist_satellite/llm.py @@ -5,6 +5,8 @@ from homeassistant.helpers import intent from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool +from .const import DOMAIN + @callback def async_get_tools( @@ -17,7 +19,7 @@ def async_get_tools( # assist_satellite registers the broadcast intent when it is set up, and # this platform is only queried once that has happened. tools: list[Tool] = [ - IntentTool(handler.intent_type, handler) + IntentTool(f"{DOMAIN}__{handler.intent_type}", handler) for handler in intent.async_get(hass) if handler.intent_type == intent.INTENT_BROADCAST ] diff --git a/homeassistant/components/calendar/llm.py b/homeassistant/components/calendar/llm.py index d92466377f58b5..3c2c47c4f49793 100644 --- a/homeassistant/components/calendar/llm.py +++ b/homeassistant/components/calendar/llm.py @@ -21,7 +21,7 @@ class CalendarGetEventsTool(Tool): """LLM Tool allowing querying a calendar.""" - name = "calendar_get_events" + name = "calendar__get_events" description = ( "Get events from a calendar. " "When asked if something happens, search the whole week. " diff --git a/homeassistant/components/climate/llm.py b/homeassistant/components/climate/llm.py index 31a8e3f1e5c0ee..b42b6dea79f927 100644 --- a/homeassistant/components/climate/llm.py +++ b/homeassistant/components/climate/llm.py @@ -30,7 +30,7 @@ def async_get_tools( return None tools: list[Tool] = [ - IntentTool(handler.intent_type, handler) + IntentTool(f"{DOMAIN}__{handler.intent_type}", handler) for handler in intent.async_get(hass) if handler.intent_type in LLM_INTENTS ] diff --git a/homeassistant/components/fan/llm.py b/homeassistant/components/fan/llm.py index 5296e634f09926..21284eb8f636bd 100644 --- a/homeassistant/components/fan/llm.py +++ b/homeassistant/components/fan/llm.py @@ -31,7 +31,7 @@ def async_get_tools( return None tools: list[Tool] = [ - IntentTool(handler.intent_type, handler) + IntentTool(f"{DOMAIN}__{handler.intent_type}", handler) for handler in intent.async_get(hass) if handler.intent_type in LLM_INTENTS ] diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index ec1b854d282bec..217696dd138872 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -179,6 +179,7 @@ def _get_entity_descriptions( event_type=EventTypeEnum.BIRTHDAY, name=None, entity_id=None, + ignore_availability=True, ) ) # Create an optional disabled by default entity for Work Location @@ -191,6 +192,7 @@ def _get_entity_descriptions( name=None, entity_id=None, entity_registry_enabled_default=False, + ignore_availability=True, ) ) return entity_descriptions diff --git a/homeassistant/components/google_health/config_flow.py b/homeassistant/components/google_health/config_flow.py index 25772446bbcdc4..1192cee386d473 100644 --- a/homeassistant/components/google_health/config_flow.py +++ b/homeassistant/components/google_health/config_flow.py @@ -8,7 +8,8 @@ from google_health_api.const import HealthApiScope from google_health_api.exceptions import ( GoogleHealthApiError, - HealthApiForbiddenException, + HealthApiScopeInsufficientException, + HealthApiServiceDisabledException, ) from homeassistant.config_entries import ( @@ -81,19 +82,22 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResu try: identity = await api.get_identity() - except HealthApiForbiddenException as err: + except HealthApiServiceDisabledException as err: _LOGGER.error("Error getting Google Health identity: %s", err) return self.async_abort( reason="api_not_enabled", description_placeholders={"url": API_CONSOLE_URL}, ) + except HealthApiScopeInsufficientException as err: + _LOGGER.error("Error getting Google Health identity: %s", err) + return self.async_abort(reason="missing_profile_scope") except GoogleHealthApiError as err: _LOGGER.error("Error getting Google Health identity: %s", err) return self.async_abort(reason="cannot_connect") if not identity.health_user_id: _LOGGER.error("Google Health identity has no health_user_id") - return self.async_abort(reason="cannot_connect") + return self.async_abort(reason="missing_profile_scope") await self.async_set_unique_id(identity.health_user_id) if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE): @@ -110,7 +114,7 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResu try: userinfo = await api.get_user_info() display_name = userinfo.given_name or userinfo.name - except Exception as err: # pylint: disable=broad-except # noqa: BLE001 + except GoogleHealthApiError as err: _LOGGER.warning("Error fetching user profile name: %s", err) return self.async_create_entry( diff --git a/homeassistant/components/google_health/strings.json b/homeassistant/components/google_health/strings.json index d0f13b6778a5ae..1e50e1cf2c4ac4 100644 --- a/homeassistant/components/google_health/strings.json +++ b/homeassistant/components/google_health/strings.json @@ -10,7 +10,7 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "cannot_connect": "Failed to connect.", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "missing_profile_scope": "Missing required Google Health profile read permission.", + "missing_profile_scope": "Missing required Google Health profile read permission. Please try again and select the right permission.", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", diff --git a/homeassistant/components/homeassistant/llm.py b/homeassistant/components/homeassistant/llm.py index c851859abf10ba..0ca9d208c967d4 100644 --- a/homeassistant/components/homeassistant/llm.py +++ b/homeassistant/components/homeassistant/llm.py @@ -36,7 +36,7 @@ DYNAMIC_CONTEXT_PROMPT = ( "You ARE equipped to answer questions about the" " current state of\n" - "the home using the `GetLiveContext` tool." + "the home using the `homeassistant__GetLiveContext` tool." " This is a primary function." " Do not state you lack the\n" "functionality if the question requires live data.\n" @@ -50,7 +50,7 @@ ' "What mode is the thermostat in?",' ' "What is the temperature outside?"):\n' " 1. Recognize this requires live data.\n" - " 2. You MUST call `GetLiveContext`." + " 2. You MUST call `homeassistant__GetLiveContext`." " This tool will provide the needed real-time" " information (like temperature from the local" " weather, lock status, etc.).\n" @@ -198,7 +198,7 @@ class GetLiveContextTool(Tool): returns state for entities based on intent parameters. """ - name = "GetLiveContext" + name = "homeassistant__GetLiveContext" description = ( "Provides real-time information about the" " CURRENT state, value, or mode of devices," diff --git a/homeassistant/components/humidifier/llm.py b/homeassistant/components/humidifier/llm.py index d799345b7e768f..dbc00a2f3ee864 100644 --- a/homeassistant/components/humidifier/llm.py +++ b/homeassistant/components/humidifier/llm.py @@ -28,7 +28,7 @@ def async_get_tools( return None tools: list[Tool] = [ - IntentTool(handler.intent_type, handler) + IntentTool(f"{DOMAIN}__{handler.intent_type}", handler) for handler in intent.async_get(hass) if handler.intent_type in LLM_INTENTS ] diff --git a/homeassistant/components/iammeter/manifest.json b/homeassistant/components/iammeter/manifest.json index 22831767e622c3..c6b0ab828cd785 100644 --- a/homeassistant/components/iammeter/manifest.json +++ b/homeassistant/components/iammeter/manifest.json @@ -1,7 +1,7 @@ { "domain": "iammeter", "name": "IamMeter", - "codeowners": ["@lewei50"], + "codeowners": ["@lewei50", "@IAMMETER", "@miwyf"], "documentation": "https://www.home-assistant.io/integrations/iammeter", "iot_class": "local_polling", "loggers": ["iammeter"], diff --git a/homeassistant/components/intent/llm.py b/homeassistant/components/intent/llm.py index fa5c27e22bb5ff..0825890df23fed 100644 --- a/homeassistant/components/intent/llm.py +++ b/homeassistant/components/intent/llm.py @@ -16,6 +16,7 @@ ) from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool +from .const import DOMAIN from .timers import async_device_supports_timers # Generic intents exposed as LLM tools regardless of a timer-capable device. @@ -40,7 +41,7 @@ DEVICE_CONTROL_TOOL_USAGE_PROMPT = ( "When controlling Home Assistant always call the intent tools. " - "Use HassTurnOn to lock and HassTurnOff to unlock a lock. " + "Use intent__HassTurnOn to lock and intent__HassTurnOff to unlock a lock. " "When controlling a device, prefer passing just name and domain. " "When controlling an area, prefer passing just area name and domain." ) @@ -75,7 +76,7 @@ def async_get_tools( ] tools: list[Tool] = [ - IntentTool(handler.intent_type, handler) for handler in handlers + IntentTool(f"{DOMAIN}__{handler.intent_type}", handler) for handler in handlers ] if not tools: return None diff --git a/homeassistant/components/intent_script/llm.py b/homeassistant/components/intent_script/llm.py index bc13382248dac8..c72277b923f735 100644 --- a/homeassistant/components/intent_script/llm.py +++ b/homeassistant/components/intent_script/llm.py @@ -8,7 +8,7 @@ from homeassistant.helpers import intent from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool -from . import ScriptIntentHandler +from . import DOMAIN, ScriptIntentHandler @callback @@ -43,7 +43,8 @@ def async_get_tools( # valid tool names. tools: list[Tool] = [ IntentTool( - unicode_slug.slugify(handler.intent_type, separator="_", lowercase=False), + f"{DOMAIN}__" + + unicode_slug.slugify(handler.intent_type, separator="_", lowercase=False), handler, ) for handler in handlers diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index 155700978cc1fd..5f68697548d182 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -201,16 +201,18 @@ def async_register(self) -> None: @callback def _init_expose_state(self) -> None: - """Initialize state of all exposures.""" - init_state = self.hass.states.get(self.entity_id) + """Initialize state of all exposures from the current HA state.""" + state = self.hass.states.get(self.entity_id) for option, xknx_expose in self._exposures: - state_value = self._get_expose_value(init_state, option) + expose_value = self._get_expose_value(state, option) + if expose_value is None: + continue try: - xknx_expose.sensor_value.value = state_value + xknx_expose.initialize_value(expose_value) except ConversionError: _LOGGER.exception( "Error setting value %s for expose sensor %s", - state_value, + expose_value, xknx_expose.name, ) @@ -280,11 +282,24 @@ def _get_expose_value( async def _async_entity_changed(self, event: Event[EventStateChangedData]) -> None: """Handle entity change for all options.""" new_state = event.data["new_state"] + async with TaskGroup() as tg: for option, xknx_expose in self._exposures: expose_value = self._get_expose_value(new_state, option) if expose_value is None: continue + + if xknx_expose.sensor_value.value is None: + try: + xknx_expose.initialize_value(expose_value) + except ConversionError: + _LOGGER.exception( + "Error setting value %s for expose sensor %s", + expose_value, + xknx_expose.name, + ) + continue + tg.create_task(self._async_set_knx_value(xknx_expose, expose_value)) async def _async_set_knx_value( diff --git a/homeassistant/components/lawn_mower/llm.py b/homeassistant/components/lawn_mower/llm.py index c51d5ecdcbdef3..16b9b5f7b8ce99 100644 --- a/homeassistant/components/lawn_mower/llm.py +++ b/homeassistant/components/lawn_mower/llm.py @@ -31,7 +31,7 @@ def async_get_tools( return None tools: list[Tool] = [ - IntentTool(handler.intent_type, handler) + IntentTool(f"{DOMAIN}__{handler.intent_type}", handler) for handler in intent.async_get(hass) if handler.intent_type in LLM_INTENTS ] diff --git a/homeassistant/components/light/llm.py b/homeassistant/components/light/llm.py index 5570245444f1f0..17df38c98e962c 100644 --- a/homeassistant/components/light/llm.py +++ b/homeassistant/components/light/llm.py @@ -31,7 +31,7 @@ def async_get_tools( return None tools: list[Tool] = [ - IntentTool(handler.intent_type, handler) + IntentTool(f"{DOMAIN}__{handler.intent_type}", handler) for handler in intent.async_get(hass) if handler.intent_type in LLM_INTENTS ] diff --git a/homeassistant/components/llama_cpp/entity.py b/homeassistant/components/llama_cpp/entity.py index 605c9ffb9793e7..39481f87a0e722 100644 --- a/homeassistant/components/llama_cpp/entity.py +++ b/homeassistant/components/llama_cpp/entity.py @@ -37,6 +37,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, llm from homeassistant.helpers.entity import Entity +from homeassistant.helpers.json import json_dumps from .api import api_error_handler from .const import ( @@ -102,7 +103,7 @@ def _convert_content_to_chat_message( return ChatCompletionToolMessageParam( role="tool", tool_call_id=content.tool_call_id, - content=json.dumps(content.tool_result), + content=json_dumps(content.tool_result), ) role: Literal["user", "assistant", "system"] = content.role @@ -123,7 +124,7 @@ def _convert_content_to_chat_message( type="function", id=tool_call.id, function=Function( - arguments=json.dumps(tool_call.tool_args), + arguments=json_dumps(tool_call.tool_args), name=tool_call.tool_name, ), ) @@ -175,7 +176,7 @@ def _convert_content_to_param( return ChatCompletionToolMessageParam( role="tool", tool_call_id=content.tool_call_id, - content=json.dumps(content.tool_result), + content=json_dumps(content.tool_result), ) if not isinstance(content, conversation.AssistantContent) or not content.tool_calls: if isinstance(content, conversation.SystemContent): @@ -195,7 +196,7 @@ def _convert_content_to_param( ChatCompletionMessageToolCallParam( id=tool_call.id, function=Function( - arguments=json.dumps(tool_call.tool_args), + arguments=json_dumps(tool_call.tool_args), name=tool_call.tool_name, ), type="function", diff --git a/homeassistant/components/llm/__init__.py b/homeassistant/components/llm/__init__.py index 1f91b9960a11dc..e64fd7a02a6860 100644 --- a/homeassistant/components/llm/__init__.py +++ b/homeassistant/components/llm/__init__.py @@ -6,6 +6,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.frame import ReportBehavior, report_usage from homeassistant.helpers.integration_platform import LazyIntegrationPlatforms from homeassistant.helpers.llm import ( API, @@ -17,6 +18,7 @@ selector_serializer, ) from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import async_get_issue_integration from homeassistant.util.hass_dict import HassKey from .const import DOMAIN @@ -26,6 +28,8 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +TOOL_PREFIX_BREAKS_IN_HA_VERSION = "2027.3" + DATA_PLATFORMS: HassKey[LazyIntegrationPlatforms[LLMToolsPlatformProtocol]] = HassKey( "llm_platforms" ) @@ -87,12 +91,38 @@ async def async_get_tools( continue if result is None: continue + _async_report_unprefixed_tools(hass, domain, result.tools) tools.extend(result.tools) if result.prompt: prompts.append(result.prompt) return LLMTools(tools=tools, prompt="\n".join(prompts) if prompts else None) +@callback +def _async_report_unprefixed_tools( + hass: HomeAssistant, domain: str, tools: list[Tool] +) -> None: + """Report tools that are not prefixed with the domain offering them.""" + prefix = f"{domain}__" + unprefixed = [tool.name for tool in tools if not tool.name.startswith(prefix)] + if not unprefixed: + return + + integration = async_get_issue_integration(hass, domain) + report_usage( + f"provides LLM tools that are not prefixed with '{prefix}': " + f"{', '.join(sorted(unprefixed))}", + breaks_in_ha_version=TOOL_PREFIX_BREAKS_IN_HA_VERSION, + core_behavior=ReportBehavior.LOG, + core_integration_behavior=ReportBehavior.LOG, + custom_integration_behavior=ReportBehavior.LOG, + integration_domain=domain, + level=logging.WARNING + if integration and not integration.is_built_in + else logging.ERROR, + ) + + class AssistAPI(API): """API exposing Assist API to LLMs.""" diff --git a/homeassistant/components/llm/llm.py b/homeassistant/components/llm/llm.py index c63f4098c24599..8061a06ddad35f 100644 --- a/homeassistant/components/llm/llm.py +++ b/homeassistant/components/llm/llm.py @@ -13,7 +13,7 @@ class GetDateTimeTool(Tool): """Tool for getting the current date and time.""" - name = "GetDateTime" + name = "llm__GetDateTime" description = "Provides the current date and time." @override diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py index 82ccbcd2cf13e2..2df7d3a3d27c67 100644 --- a/homeassistant/components/mcp_server/server.py +++ b/homeassistant/components/mcp_server/server.py @@ -30,7 +30,7 @@ SNAPSHOT_RESOURCE_URI = "homeassistant://assist/context-snapshot" SNAPSHOT_RESOURCE_URL = AnyUrl(SNAPSHOT_RESOURCE_URI) SNAPSHOT_RESOURCE_MIME_TYPE = "text/plain" -LIVE_CONTEXT_TOOL_NAME = "GetLiveContext" +LIVE_CONTEXT_TOOL_NAME = "homeassistant__GetLiveContext" def _has_live_context_tool(llm_api: llm.APIInstance) -> bool: @@ -115,7 +115,7 @@ async def handle_list_resources() -> list[types.Resource]: title="Assist context snapshot", description=( "A snapshot of the current Assist context, matching the" - " existing GetLiveContext tool output." + " existing homeassistant__GetLiveContext tool output." ), mimeType=SNAPSHOT_RESOURCE_MIME_TYPE, ) diff --git a/homeassistant/components/media_player/llm.py b/homeassistant/components/media_player/llm.py index aa6778835a64af..11bc45a40936e2 100644 --- a/homeassistant/components/media_player/llm.py +++ b/homeassistant/components/media_player/llm.py @@ -51,7 +51,7 @@ def async_get_tools( return None tools: list[Tool] = [ - IntentTool(handler.intent_type, handler) + IntentTool(f"{DOMAIN}__{handler.intent_type}", handler) for handler in intent.async_get(hass) if handler.intent_type in LLM_INTENTS ] diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index e8266d13a8d5a8..f6cb4fac8a36b0 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -9,7 +9,7 @@ from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType -from .connection import async_get_unit +from .connection import async_get_temporary_unit, async_get_unit from .const import DOMAIN from .modbus import DATA_MODBUS_HUBS, ModbusHub, async_modbus_setup from .schemas import CONFIG_SCHEMA @@ -17,6 +17,7 @@ __all__ = [ "CONFIG_SCHEMA", "ModbusHub", + "async_get_temporary_unit", "async_get_unit", "get_hub", ] diff --git a/homeassistant/components/modbus/connection.py b/homeassistant/components/modbus/connection.py index bbbf79ba16e562..2ff9fbc445adf7 100644 --- a/homeassistant/components/modbus/connection.py +++ b/homeassistant/components/modbus/connection.py @@ -1,7 +1,10 @@ """Hand out Modbus units over connections shared between integrations.""" +from collections.abc import AsyncIterator, Callable, Coroutine +from contextlib import asynccontextmanager from dataclasses import dataclass import logging +from typing import Any from modbus_connection import ( ModbusSerialParams, @@ -41,17 +44,10 @@ class _SharedConnection: @callback -def async_get_unit( - hass: HomeAssistant, - entry: ConfigEntry, - params: ModbusParams, - unit_id: int, -) -> ModbusUnit: - """Return a unit on the connection these credentials describe. - - Consumers of one device share a connection, so their requests serialize - behind its lock. It is closed when the last config entry holding a unit on - it unloads. +def _async_acquire( + hass: HomeAssistant, params: ModbusParams +) -> tuple[ModbusConnection, Callable[[], Coroutine[Any, Any, None]]]: + """Take a hold on the connection these credentials describe. Raises `HomeAssistantError` if the device is already in use over different link settings, which cannot both be honoured on one connection. @@ -70,7 +66,7 @@ def async_get_unit( shared.consumers += 1 - async def _release() -> None: + async def release() -> None: """Give up this hold, closing behind the last one.""" shared.consumers -= 1 if shared.consumers or connections.get(endpoint) is not shared: @@ -79,5 +75,47 @@ async def _release() -> None: _LOGGER.debug("Closing the Modbus connection to %s", endpoint) await shared.connection.close() - entry.async_on_unload(_release) - return shared.connection.for_unit(unit_id) + return shared.connection, release + + +@callback +def async_get_unit( + hass: HomeAssistant, + entry: ConfigEntry, + params: ModbusParams, + unit_id: int, +) -> ModbusUnit: + """Return a unit on the connection these credentials describe. + + Consumers of one device share a connection, so their requests serialize + behind its lock. It is closed when the last config entry holding a unit on + it unloads. + + Raises `HomeAssistantError` if the device is already in use over different + link settings, which cannot both be honoured on one connection. + """ + connection, release = _async_acquire(hass, params) + entry.async_on_unload(release) + return connection.for_unit(unit_id) + + +@asynccontextmanager +async def async_get_temporary_unit( + hass: HomeAssistant, + params: ModbusParams, + unit_id: int, +) -> AsyncIterator[ModbusUnit]: + """Hold a unit on the connection these credentials describe for the context. + + For config flows, which have no config entry yet to tie a hold to. A + connection already held by a config entry is shared and stays up; one + opened here is closed on exit. + + Raises `HomeAssistantError` if the device is already in use over different + link settings, which cannot both be honoured on one connection. + """ + connection, release = _async_acquire(hass, params) + try: + yield connection.for_unit(unit_id) + finally: + await release() diff --git a/homeassistant/components/openevse/button.py b/homeassistant/components/openevse/button.py index 738195ce876291..44e76a8b2ca3bf 100644 --- a/homeassistant/components/openevse/button.py +++ b/homeassistant/components/openevse/button.py @@ -94,5 +94,5 @@ def __init__( @override async def async_press(self) -> None: """Press the button.""" - with openevse_exception_handler(0.0): + with openevse_exception_handler(): await self.entity_description.press_fn(self.coordinator.charger) diff --git a/homeassistant/components/openevse/helpers.py b/homeassistant/components/openevse/helpers.py index 32989aa9c51177..66b4afa8833433 100644 --- a/homeassistant/components/openevse/helpers.py +++ b/homeassistant/components/openevse/helpers.py @@ -8,6 +8,7 @@ from openevsehttp.exceptions import ( AuthenticationError, ParseJSONError, + UnknownError, UnsupportedFeature, ) @@ -46,6 +47,8 @@ def openevse_exception_handler(value: Any = None) -> Iterator[None]: ServerTimeoutError, ContentTypeError, ParseJSONError, + UnknownError, + RuntimeError, ) as err: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 62d5c3775d7687..7d3ed3ab53e4db 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -131,7 +131,7 @@ SensorEntityDescription( key="network_tx", translation_key="network_tx", - native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -142,7 +142,7 @@ SensorEntityDescription( key="network_rx", translation_key="network_rx", - native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, diff --git a/homeassistant/components/script/llm.py b/homeassistant/components/script/llm.py index a669b67091103e..bd456471cca1be 100644 --- a/homeassistant/components/script/llm.py +++ b/homeassistant/components/script/llm.py @@ -30,9 +30,7 @@ def __init__( super().__init__(hass, DOMAIN, action) - self.name = script_name - if self.name[0].isdigit(): - self.name = "_" + self.name + self.name = f"{DOMAIN}__{script_name}" if entity_entry and ( aliases := er.async_get_entity_aliases(hass, entity_entry) diff --git a/homeassistant/components/todo/llm.py b/homeassistant/components/todo/llm.py index 189c03040fb279..7aaf3334f4aa84 100644 --- a/homeassistant/components/todo/llm.py +++ b/homeassistant/components/todo/llm.py @@ -32,7 +32,7 @@ class TodoGetItemsTool(Tool): """LLM Tool allowing querying a to-do list.""" - name = "todo_get_items" + name = "todo__get_items" description = ( "Query a to-do list to find out what items are on it. " "Use this to answer questions like " @@ -115,7 +115,7 @@ def async_get_tools( tools: list[Tool] = [TodoGetItemsTool(names)] tools.extend( - IntentTool(handler.intent_type, handler) + IntentTool(f"{DOMAIN}__{handler.intent_type}", handler) for handler in intent.async_get(hass) if handler.intent_type in LLM_INTENTS ) diff --git a/homeassistant/components/tonewinner/__init__.py b/homeassistant/components/tonewinner/__init__.py new file mode 100644 index 00000000000000..fab054dea018b9 --- /dev/null +++ b/homeassistant/components/tonewinner/__init__.py @@ -0,0 +1,48 @@ +"""Set up Tonewinner from a config entry.""" + +import logging + +from tonewinner_rs232 import TonewinnerReceiver + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv + +from .const import CONF_SERIAL_PORT, DOMAIN + +PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER] + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + +type TonewinnerConfigEntry = ConfigEntry[TonewinnerReceiver] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: TonewinnerConfigEntry) -> bool: + """Set up Tonewinner from a config entry.""" + port = entry.data[CONF_SERIAL_PORT] + + receiver = TonewinnerReceiver(port) + try: + await receiver.connect() + await receiver.query_state() + except OSError as err: + await receiver.disconnect() + raise ConfigEntryNotReady(f"Unable to connect to {port}") from err + + _LOGGER.info("Connected to Tonewinner receiver on %s", port) + + entry.runtime_data = receiver + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: TonewinnerConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await entry.runtime_data.disconnect() + return unload_ok diff --git a/homeassistant/components/tonewinner/config_flow.py b/homeassistant/components/tonewinner/config_flow.py new file mode 100644 index 00000000000000..71d46d694c9530 --- /dev/null +++ b/homeassistant/components/tonewinner/config_flow.py @@ -0,0 +1,120 @@ +"""Tonewinner configuration flow.""" + +import logging +from typing import Any, override + +from tonewinner_rs232 import ReceiverInfo, TonewinnerReceiver +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntryState, + ConfigFlow as ConfigEntryFlow, + ConfigFlowResult, +) +from homeassistant.const import CONF_MODEL +from homeassistant.helpers.selector import SerialPortSelector + +from .const import CONF_SERIAL_PORT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_SERIAL_PORT): SerialPortSelector(), + } +) + + +class TonewinnerConfigFlow(ConfigEntryFlow, domain=DOMAIN): + """Handle the Tonewinner config flow.""" + + async def _async_probe_receiver(self, port: str) -> str | None: + """Verify a receiver answers on the port and return its model.""" + receiver = TonewinnerReceiver(port) + try: + await receiver.connect() + info: ReceiverInfo | None = await receiver.query_info() + finally: + await receiver.disconnect() + return info.model if info else None + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle initial step of configuration flow.""" + errors = {} + if user_input is not None: + port = user_input[CONF_SERIAL_PORT] + # Check before probing: an entry already holding the port would + # make the probe fail even though the receiver is reachable. + self._async_abort_entries_match({CONF_SERIAL_PORT: port}) + try: + model = await self._async_probe_receiver(port) + except OSError as err: + _LOGGER.warning("Failed to probe receiver on %s: %s", port, err) + errors["base"] = "cannot_connect" + else: + data: dict[str, Any] = {CONF_SERIAL_PORT: port} + title = "Tonewinner" + if model: + data[CONF_MODEL] = model + title = model + return self.async_create_entry(title=title, data=data) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the serial port connection.""" + errors = {} + entry = self._get_reconfigure_entry() + + if user_input is not None: + port = user_input[CONF_SERIAL_PORT] + self._async_abort_entries_match({CONF_SERIAL_PORT: port}) + + # Free the port before probing: a held port would race the probe, + # and an unchanged port deserves a fresh model check (device swap). + if entry.state is ConfigEntryState.LOADED: + if not await self.hass.config_entries.async_unload(entry.entry_id): + # The receiver is still connected; probing would race it. + _LOGGER.warning( + "Failed to unload %s before reconfiguring", entry.title + ) + return self.async_abort(reason="reconfigure_unload_failed") + else: + entry.async_cancel_retry_setup() + + try: + model = await self._async_probe_receiver(port) + except OSError as err: + _LOGGER.warning("Failed to probe receiver on %s: %s", port, err) + errors["base"] = "cannot_connect" + # Bring the previous configuration back up so the receiver + # keeps working while the user retries. + await self.hass.config_entries.async_setup(entry.entry_id) + else: + data: dict[str, Any] = {CONF_SERIAL_PORT: port} + title = "Tonewinner" + if model: + data[CONF_MODEL] = model + title = model + return self.async_update_reload_and_abort( + entry, + data=data, + title=title, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, entry.data + ), + errors=errors, + ) diff --git a/homeassistant/components/tonewinner/const.py b/homeassistant/components/tonewinner/const.py new file mode 100644 index 00000000000000..12d97f8d9be5cc --- /dev/null +++ b/homeassistant/components/tonewinner/const.py @@ -0,0 +1,5 @@ +"""Constants for the Tonewinner integration.""" + +DOMAIN = "tonewinner" + +CONF_SERIAL_PORT = "serial_port" diff --git a/homeassistant/components/tonewinner/manifest.json b/homeassistant/components/tonewinner/manifest.json new file mode 100644 index 00000000000000..6684b9727ffada --- /dev/null +++ b/homeassistant/components/tonewinner/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "tonewinner", + "name": "Tonewinner", + "codeowners": ["@emma-sg"], + "config_flow": true, + "dependencies": ["usb"], + "documentation": "https://www.home-assistant.io/integrations/tonewinner", + "integration_type": "device", + "iot_class": "local_push", + "loggers": ["tonewinner_rs232"], + "quality_scale": "silver", + "requirements": ["tonewinner-rs232==1.1.0"] +} diff --git a/homeassistant/components/tonewinner/media_player.py b/homeassistant/components/tonewinner/media_player.py new file mode 100644 index 00000000000000..4ebf4e783b4140 --- /dev/null +++ b/homeassistant/components/tonewinner/media_player.py @@ -0,0 +1,234 @@ +"""Tonewinner media player.""" + +import logging +from typing import override + +from tonewinner_rs232 import INPUT_SOURCE_NAMES, SOUND_MODE_LABELS, ReceiverState + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, +) +from homeassistant.const import CONF_MODEL +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import TonewinnerConfigEntry +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +INPUT_SOURCES = {name: code for code, name in INPUT_SOURCE_NAMES.items()} + +SOUND_MODES: dict[str, str] = {} +for _code, _label in SOUND_MODE_LABELS.items(): + # First wins so firmware misspellings (DITECT, ALLSTREO) never shadow + # the canonical codes. + SOUND_MODES.setdefault(_label, _code) + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TonewinnerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the media player entity.""" + async_add_entities([TonewinnerMediaPlayer(config_entry)]) + + +class TonewinnerMediaPlayer(MediaPlayerEntity): + """Tonewinner media player.""" + + _attr_device_class = MediaPlayerDeviceClass.RECEIVER + _attr_should_poll = False + _attr_supported_features = ( + MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.SELECT_SOUND_MODE + ) + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, entry: TonewinnerConfigEntry) -> None: + """Initialize the media player.""" + self._entry = entry + self._receiver = entry.runtime_data + self._attr_unique_id = entry.entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer="Tonewinner", + model=entry.data.get(CONF_MODEL), + ) + self._attr_available = False + + self._attr_state = MediaPlayerState.OFF + self._attr_volume_level = 0.5 + self._attr_is_volume_muted = False + self._attr_source = None + self._attr_sound_mode = None + + self._attr_source_list = list(INPUT_SOURCES) + self._attr_sound_mode_list = list(SOUND_MODES) + + @override + async def async_added_to_hass(self) -> None: + """Subscribe to state changes when entity is added.""" + await super().async_added_to_hass() + self.async_on_remove(self._receiver.subscribe(self._on_state_change)) + if self._receiver.connected: + self._apply_state(self._receiver.state) + else: + self.hass.config_entries.async_schedule_reload(self._entry.entry_id) + + @callback + def _on_state_change(self, state: ReceiverState | None) -> None: + """Handle state changes from the receiver.""" + if state is None: + if self._attr_available: + _LOGGER.info("Connection to the Tonewinner receiver was lost") + self._attr_available = False + # The library never reconnects on its own; reload instead so + # HA's retry loop restores the connection. Schedule through + # hass so the entry does not track (and wait on) this task. + self.hass.config_entries.async_schedule_reload(self._entry.entry_id) + else: + if not self._attr_available: + _LOGGER.info("Connection to the Tonewinner receiver was restored") + self._apply_state(state) + self.async_write_ha_state() + + @callback + def _apply_state(self, state: ReceiverState) -> None: + """Apply receiver state to HA entity attributes.""" + self._attr_available = True + + if state.power is not None: + self._attr_state = ( + MediaPlayerState.ON if state.power else MediaPlayerState.OFF + ) + if not state.power: + self._attr_source = None + + if state.volume is not None: + self._attr_volume_level = state.volume / 80 + + if state.mute is not None: + self._attr_is_volume_muted = state.mute + + # The library retains the last known source across power transitions; + # while powered down no input is active, so do not surface it. + if state.source_name is not None and state.power is not False: + self._attr_source = self._resolve_source( + state.source_name, state.audio_source + ) + + if state.sound_mode_label is not None: + self._attr_sound_mode = state.sound_mode_label + + def _resolve_source(self, source_name: str, audio_source: str | None) -> str | None: + """Resolve a device-reported source name to a display name.""" + if source_name == "eARC/ARC": + source_name = "ARC" + + for name, code in INPUT_SOURCES.items(): + if source_name.lower() in (name.lower(), code.lower()): + return name + + if audio_source in INPUT_SOURCE_NAMES: + return INPUT_SOURCE_NAMES[audio_source] + + return source_name + + @override + async def async_turn_on(self) -> None: + """Turn the media player on.""" + try: + await self._receiver.power_on() + except OSError as err: + raise HomeAssistantError(f"Failed to turn on: {err}") from err + + @override + async def async_turn_off(self) -> None: + """Turn the media player off.""" + try: + await self._receiver.power_off() + except OSError as err: + raise HomeAssistantError(f"Failed to turn off: {err}") from err + self._attr_state = MediaPlayerState.OFF + self._attr_source = None + self.async_write_ha_state() + + @override + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level (HA 0.0-1.0, device 0-80 in half steps). + + Snapping to the half-step grid keeps the device echo equal to what we + sent, so the slider does not jump after an off-grid value is rounded + by the firmware. + """ + try: + await self._receiver.set_volume(round(volume * 160) / 2) + except OSError as err: + raise HomeAssistantError(f"Failed to set volume: {err}") from err + self.async_write_ha_state() + + @override + async def async_volume_up(self) -> None: + """Volume up.""" + try: + await self._receiver.volume_up() + except OSError as err: + raise HomeAssistantError(f"Failed to step volume up: {err}") from err + + @override + async def async_volume_down(self) -> None: + """Volume down.""" + try: + await self._receiver.volume_down() + except OSError as err: + raise HomeAssistantError(f"Failed to step volume down: {err}") from err + + @override + async def async_mute_volume(self, mute: bool) -> None: + """Mute or unmute.""" + command = self._receiver.mute_on if mute else self._receiver.mute_off + try: + await command() + except OSError as err: + action = "mute" if mute else "unmute" + raise HomeAssistantError(f"Failed to {action}: {err}") from err + + @override + async def async_select_source(self, source: str) -> None: + """Select input source.""" + if source not in INPUT_SOURCES: + raise HomeAssistantError(f"Unknown source: {source}") + + try: + await self._receiver.select_source(INPUT_SOURCES[source]) + except OSError as err: + raise HomeAssistantError( + f"Failed to select source {source}: {err}" + ) from err + + @override + async def async_select_sound_mode(self, sound_mode: str) -> None: + """Select sound mode.""" + if sound_mode not in SOUND_MODES: + raise HomeAssistantError(f"Unknown sound mode: {sound_mode}") + try: + await self._receiver.select_sound_mode(SOUND_MODES[sound_mode]) + except OSError as err: + raise HomeAssistantError( + f"Failed to select sound mode {sound_mode}: {err}" + ) from err diff --git a/homeassistant/components/tonewinner/quality_scale.yaml b/homeassistant/components/tonewinner/quality_scale.yaml new file mode 100644 index 00000000000000..2f6b756bf6e2d7 --- /dev/null +++ b/homeassistant/components/tonewinner/quality_scale.yaml @@ -0,0 +1,77 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not have any actions. + appropriate-polling: + status: done + comment: | + The integration receives push updates from the device via the + tonewinner-rs232 library subscription API. A bounded retry loop + re-queries the source only while the device is on and its source + is still unknown. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: >- + The integration communicates over a local serial connection and does + not use authentication. + test-coverage: done + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: done + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: done diff --git a/homeassistant/components/tonewinner/strings.json b/homeassistant/components/tonewinner/strings.json new file mode 100644 index 00000000000000..b8738e20900f64 --- /dev/null +++ b/homeassistant/components/tonewinner/strings.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "reconfigure_unload_failed": "The existing configuration could not be unloaded. Please try again." + }, + "error": { + "cannot_connect": "Cannot connect to the serial port. Please check the port and permissions.", + "unknown": "An unknown error occurred" + }, + "step": { + "reconfigure": { + "data": { + "serial_port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "serial_port": "[%key:component::tonewinner::config::step::user::data_description::serial_port%]" + }, + "description": "Update the serial port for your Tonewinner AV receiver.", + "title": "Reconfigure Tonewinner" + }, + "user": { + "data": { + "serial_port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "serial_port": "Serial port path to connect to" + }, + "description": "Enter the serial port of your Tonewinner AV receiver.", + "title": "Setup Tonewinner" + } + } + }, + "entity": { + "media_player": { + "tonewinner": { + "name": "Tonewinner" + } + } + } +} diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 45bde637256f61..1b930c7d0418ce 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -309,15 +309,16 @@ def _async_set_device_info(self) -> None: if self._private is not None: super()._async_set_device_info() return - # public-only: no market_name/firmware_version/protect_url, and - # ``type`` only on newer firmware, so device identity is limited. The - # NVR link is omitted — an API-key-only client has no private - # bootstrap to read the NVR mac from, and resolving it publicly is - # async; the public-only config mode wires it at setup instead. + # public-only: no market_name/firmware_version/protect_url, so device + # identity is limited. The NVR link is omitted — an API-key-only client + # has no private bootstrap to read the NVR mac from, and resolving it + # publicly is async; the public-only config mode wires it at setup + # instead. public = self._public self._attr_device_info = DeviceInfo( name=public.display_name, model=public.type, + model_id=public.type, manufacturer=DEFAULT_BRAND, connections={(dr.CONNECTION_NETWORK_MAC, public.mac)}, ) diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index a6f66023a31fb9..a4414ab7abf48a 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -55,7 +55,7 @@ # the public API devices WebSocket. DEVICES_WS_SUBSCRIBED_MODELS: set[ModelType] = set() -MIN_REQUIRED_PROTECT_V = Version("7.1.0") +MIN_REQUIRED_PROTECT_V = Version("7.2.105") OUTDATED_LOG_MESSAGE = ( "You are running v%s of UniFi Protect. Minimum required version is v%s. Please" " upgrade UniFi Protect and then retry" diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index dbe0214b1be7df..42d049d6d1fd05 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -123,6 +123,7 @@ def _async_set_device_info(self) -> None: self._attr_device_info = DeviceInfo( name=public.display_name, model=public.type, + model_id=public.type, manufacturer=DEFAULT_BRAND, connections={(dr.CONNECTION_NETWORK_MAC, public.mac)}, ) diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index cba086a1ca6a86..aa49898133bcbb 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -11,7 +11,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cloud_user": "Ubiquiti Cloud users are not supported. Please use a local user instead.", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "protect_version": "Minimum required version is v7.1.0. Please upgrade UniFi Protect and then retry." + "protect_version": "Minimum required version is v7.2.105. Please upgrade UniFi Protect and then retry." }, "flow_title": "{name} ({ip_address})", "step": { diff --git a/homeassistant/components/vacuum/llm.py b/homeassistant/components/vacuum/llm.py index eb28c4a9ca2532..37c366a32b2edc 100644 --- a/homeassistant/components/vacuum/llm.py +++ b/homeassistant/components/vacuum/llm.py @@ -39,7 +39,7 @@ def async_get_tools( return None tools: list[Tool] = [ - IntentTool(handler.intent_type, handler) + IntentTool(f"{DOMAIN}__{handler.intent_type}", handler) for handler in intent.async_get(hass) if handler.intent_type in LLM_INTENTS ] diff --git a/homeassistant/components/vesync/coordinator.py b/homeassistant/components/vesync/coordinator.py index fcf5954a1d1b7e..a7ca798873dd83 100644 --- a/homeassistant/components/vesync/coordinator.py +++ b/homeassistant/components/vesync/coordinator.py @@ -1,7 +1,8 @@ """Class to manage VeSync data updates.""" -from datetime import datetime, timedelta +from datetime import timedelta import logging +import time from typing import override from pyvesync import VeSync @@ -22,7 +23,7 @@ class VeSyncDataCoordinator(DataUpdateCoordinator[None]): """Class representing data coordinator for VeSync devices.""" config_entry: VesyncConfigEntry - update_time: datetime | None = None + update_time: float | None = None def __init__( self, hass: HomeAssistant, config_entry: VesyncConfigEntry, manager: VeSync @@ -43,9 +44,7 @@ def should_update_energy(self) -> bool: if self.update_time is None: return True - return datetime.now() - self.update_time >= timedelta( # pylint: disable=home-assistant-enforce-naive-now - seconds=UPDATE_INTERVAL_ENERGY - ) + return time.time() - self.update_time >= UPDATE_INTERVAL_ENERGY @override async def _async_update_data(self) -> None: @@ -54,7 +53,7 @@ async def _async_update_data(self) -> None: await self.manager.update_all_devices() if self.should_update_energy(): - self.update_time = datetime.now() # pylint: disable=home-assistant-enforce-naive-now + self.update_time = time.time() for outlet in self.manager.devices.outlets: await outlet.update_energy() except VeSyncError as err: diff --git a/homeassistant/components/wirelesstag/binary_sensor.py b/homeassistant/components/wirelesstag/binary_sensor.py index b3511799169076..f95ea0aa7e676f 100644 --- a/homeassistant/components/wirelesstag/binary_sensor.py +++ b/homeassistant/components/wirelesstag/binary_sensor.py @@ -85,7 +85,10 @@ def __init__( async def async_added_to_hass(self) -> None: """Register callbacks.""" tag_id = self.tag_id - event_type = self.device_class + # Use the raw event type, not the device class: the push side dispatches + # with the library's event type, and device_class is None for some + # events (e.g. dry/wet), which would never match the dispatched signal. + event_type = self._sensor_type mac = self.tag_manager_mac self.async_on_remove( async_dispatcher_connect( diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 864f89f265faa4..ca68344e5ccd31 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -796,6 +796,7 @@ "togrill", "tolo", "tomorrowio", + "tonewinner", "toon", "totalconnect", "touchline", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4ca7d7aa5aceba..c1b4b73b480dd6 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7390,6 +7390,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "tonewinner": { + "name": "Tonewinner", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "toon": { "name": "Toon", "integration_type": "device", diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index 1789bda1c79526..3ea52348a095c9 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -26,7 +26,7 @@ def default(self, o: Any) -> Any: Hand other objects to the original method. """ - if isinstance(o, datetime.datetime): + if isinstance(o, (datetime.date, datetime.time, datetime.datetime)): return o.isoformat() if isinstance(o, set): return list(o) @@ -51,7 +51,7 @@ def json_encoder_default(obj: Any) -> Any: return obj.as_dict() if isinstance(obj, Path): return obj.as_posix() - if isinstance(obj, datetime.datetime): + if isinstance(obj, (datetime.date, datetime.time, datetime.datetime)): return obj.isoformat() raise TypeError diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 5242ae951b8601..e099896d570f70 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -230,8 +230,10 @@ def __init__( ) -> None: """Init the class.""" self.name = name + self.intent_type = intent_handler.intent_type self.description = ( - intent_handler.description or f"Execute Home Assistant {self.name} intent" + intent_handler.description + or f"Execute Home Assistant {self.intent_type} intent" ) self.extra_slots = None if not (slot_schema := intent_handler.slot_schema): @@ -281,7 +283,7 @@ async def async_call( intent_response = await intent.async_handle( hass=hass, platform=llm_context.platform, - intent_type=self.name, + intent_type=self.intent_type, slots=slots, text_input=None, context=llm_context.context, diff --git a/mypy.ini b/mypy.ini index 5efa32b96073aa..82c9abc9330ae1 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5709,6 +5709,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tonewinner.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.tplink.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 674d3e6659c776..7778f88bf3510b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3222,6 +3222,9 @@ togrill-bluetooth==0.9.0 # homeassistant.components.tolo tololib==1.2.2 +# homeassistant.components.tonewinner +tonewinner-rs232==1.1.0 + # homeassistant.components.toon toonapi==0.3.0 diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index c0f41bb6f2c323..bb600cd7f6bba8 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -1785,9 +1785,9 @@ async def test_tool_search( } in tools for tool in tools: if tool["name"] in ( - "HassTurnOn", - "HassTurnOff", - "GetLiveContext", + "intent__HassTurnOn", + "intent__HassTurnOff", + "homeassistant__GetLiveContext", "tool_search_tool_bm25", ): assert "defer_loading" not in tool diff --git a/tests/components/assist_satellite/test_llm.py b/tests/components/assist_satellite/test_llm.py index 9a65b35b9f8ac0..5ace33d9865c48 100644 --- a/tests/components/assist_satellite/test_llm.py +++ b/tests/components/assist_satellite/test_llm.py @@ -31,4 +31,4 @@ def _llm_context() -> llm.LLMContext: async def test_broadcast_tool_offered(hass: HomeAssistant) -> None: """Test the broadcast intent is exposed as an LLM tool.""" result = await llm_component.async_get_tools(hass, _llm_context(), "assist") - assert "HassBroadcast" in [tool.name for tool in result.tools] + assert "assist_satellite__HassBroadcast" in [tool.name for tool in result.tools] diff --git a/tests/components/calendar/test_llm.py b/tests/components/calendar/test_llm.py index 056ff357d69ef1..aab77f8f7211a7 100644 --- a/tests/components/calendar/test_llm.py +++ b/tests/components/calendar/test_llm.py @@ -44,7 +44,7 @@ async def test_get_tools_no_exposed_calendar(hass: HomeAssistant) -> None: """Test no calendar tool is offered when no calendar is exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) result = await llm_component.async_get_tools(hass, _llm_context(), "assist") - assert "calendar_get_events" not in [tool.name for tool in result.tools] + assert "calendar__get_events" not in [tool.name for tool in result.tools] assert calendar_llm.async_get_tools(hass, _llm_context(), "assist") is None @@ -58,7 +58,7 @@ async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: llm_context = _llm_context() result = await llm_component.async_get_tools(hass, llm_context, "assist") tool = next( - (tool for tool in result.tools if tool.name == "calendar_get_events"), None + (tool for tool in result.tools if tool.name == "calendar__get_events"), None ) assert tool is not None assert tool.parameters.schema["calendar"].container == ["Mock Calendar Name"] @@ -90,7 +90,7 @@ async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: ) tool_input = llm.ToolInput( - tool_name="calendar_get_events", + tool_name="calendar__get_events", tool_args={"calendar": "Mock Calendar Name", "range": "today"}, ) now = dt_util.now() @@ -141,7 +141,7 @@ async def test_calendar_get_events_tool_not_found(hass: HomeAssistant) -> None: """Test the tool reports when the requested calendar no longer matches.""" llm_context = _llm_context() result = await llm_component.async_get_tools(hass, llm_context, "assist") - tool = next(tool for tool in result.tools if tool.name == "calendar_get_events") + tool = next(tool for tool in result.tools if tool.name == "calendar__get_events") # Unexpose after the tool (and its calendar enum) was built, so the call-time # match no longer finds the calendar. @@ -149,7 +149,7 @@ async def test_calendar_get_events_tool_not_found(hass: HomeAssistant) -> None: response = await tool.async_call( hass, llm.ToolInput( - "calendar_get_events", {"calendar": "Mock Calendar Name", "range": "today"} + "calendar__get_events", {"calendar": "Mock Calendar Name", "range": "today"} ), llm_context, ) @@ -168,5 +168,5 @@ async def test_calendar_get_events_tool_uses_aliases( async_expose_entity(hass, "conversation", entry.entity_id, True) result = await llm_component.async_get_tools(hass, _llm_context(), "assist") - tool = next(tool for tool in result.tools if tool.name == "calendar_get_events") + tool = next(tool for tool in result.tools if tool.name == "calendar__get_events") assert "Family Calendar" in tool.parameters.schema["calendar"].container diff --git a/tests/components/climate/test_llm.py b/tests/components/climate/test_llm.py index 85103a855768eb..ba91a1c5d31222 100644 --- a/tests/components/climate/test_llm.py +++ b/tests/components/climate/test_llm.py @@ -43,13 +43,13 @@ async def _tool_names(hass: HomeAssistant) -> set[str]: async def test_intent_tool_exposed(hass: HomeAssistant) -> None: """Test the intent tool is offered for an exposed climate entity.""" - assert "HassClimateSetTemperature" in await _tool_names(hass) + assert "climate__HassClimateSetTemperature" in await _tool_names(hass) async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: """Test the intent tool is hidden when no climate entity is exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) - assert "HassClimateSetTemperature" not in await _tool_names(hass) + assert "climate__HassClimateSetTemperature" not in await _tool_names(hass) assert climate_llm.async_get_tools(hass, _llm_context(), "assist") is None diff --git a/tests/components/fan/test_llm.py b/tests/components/fan/test_llm.py index 92f95aad465117..c27ac4ae652db5 100644 --- a/tests/components/fan/test_llm.py +++ b/tests/components/fan/test_llm.py @@ -43,13 +43,13 @@ async def _tool_names(hass: HomeAssistant) -> set[str]: async def test_intent_tool_exposed(hass: HomeAssistant) -> None: """Test the intent tool is offered for an exposed fan entity.""" - assert "HassFanSetSpeed" in await _tool_names(hass) + assert "fan__HassFanSetSpeed" in await _tool_names(hass) async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: """Test the intent tool is hidden when no fan entity is exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) - assert "HassFanSetSpeed" not in await _tool_names(hass) + assert "fan__HassFanSetSpeed" not in await _tool_names(hass) assert fan_llm.async_get_tools(hass, _llm_context(), "assist") is None diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index ba7f18882897ff..e6d40f0de3c87f 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -1487,6 +1487,192 @@ async def test_working_location_entity( assert state.attributes.get("message") == expected_event_message +@pytest.mark.parametrize("calendar_is_primary", [True]) +async def test_working_location_get_events( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + entity_registry: er.EntityRegistry, + mock_events_list_items: Callable[[list[dict[str, Any]]], None], + component_setup: ComponentSetup, +) -> None: + """Test get_events for working location entity with recurring and single events from diagnostics.""" + events = [ + { + **TEST_EVENT, + "id": "event-home", + "iCalUID": "event-home@google.com", + "summary": "Home", + "start": {"date": "2026-08-24"}, + "end": {"date": "2026-08-25"}, + "transparency": "transparent", + "status": "confirmed", + "eventType": "workingLocation", + "visibility": "public", + "recurrence": ["RRULE:FREQ=WEEKLY;BYDAY=MO"], + }, + { + **TEST_EVENT, + "id": "event-office", + "iCalUID": "event-office@google.com", + "summary": "Office", + "start": {"date": "2026-08-25"}, + "end": {"date": "2026-08-26"}, + "transparency": "transparent", + "status": "confirmed", + "eventType": "workingLocation", + "visibility": "public", + }, + ] + mock_events_list_items(events) + assert await component_setup() + + entity_registry.async_update_entity( + entity_id="calendar.working_location", disabled_by=None + ) + async_fire_time_changed( + hass, + dt_util.utcnow() + datetime.timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + # Query events via calendar.get_events action + response = await hass.services.async_call( + "calendar", + "get_events", + { + "start_date_time": "2026-08-24T00:00:00Z", + "end_date_time": "2026-09-01T00:00:00Z", + }, + target={"entity_id": "calendar.working_location"}, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.working_location": { + "events": [ + { + "start": "2026-08-24", + "end": "2026-08-25", + "summary": "Home", + "description": "test event", + "location": "Test Cases", + }, + { + "start": "2026-08-25", + "end": "2026-08-26", + "summary": "Office", + "description": "test event", + "location": "Test Cases", + }, + { + "start": "2026-08-31", + "end": "2026-09-01", + "summary": "Home", + "description": "test event", + "location": "Test Cases", + }, + ] + } + } + + +@pytest.mark.parametrize("calendar_is_primary", [True]) +@pytest.mark.parametrize( + "calendars_config", + [ + [ + { + "cal_id": CALENDAR_ID, + "entities": [ + { + "device_id": "primary", + "name": "Primary", + "ignore_availability": False, + "track": True, + } + ], + } + ] + ], +) +async def test_working_location_ignore_availability_false( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_calendars_yaml: None, + mock_events_list_items: Callable[[list[dict[str, Any]]], None], + component_setup: ComponentSetup, +) -> None: + """Test working location entity when primary calendar has ignore_availability=False in YAML reproduces empty events issue.""" + event = { + **TEST_EVENT, + "id": "event-home", + "iCalUID": "event-home@google.com", + "summary": "Home", + "start": {"date": "2026-08-24"}, + "end": {"date": "2026-08-25"}, + "transparency": "transparent", + "status": "confirmed", + "eventType": "workingLocation", + "visibility": "public", + } + mock_events_list_items([event]) + assert await component_setup() + + entity_entry = entity_registry.async_get("calendar.working_location") + assert entity_entry + assert entity_entry.disabled_by == RegistryEntryDisabler.INTEGRATION + + entity_registry.async_update_entity( + entity_id="calendar.working_location", disabled_by=None + ) + async_fire_time_changed( + hass, + dt_util.utcnow() + datetime.timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + # Query events via calendar.get_events action on working location entity + response = await hass.services.async_call( + "calendar", + "get_events", + { + "start_date_time": "2026-08-24T00:00:00Z", + "end_date_time": "2026-08-26T00:00:00Z", + }, + target={"entity_id": "calendar.working_location"}, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.working_location": { + "events": [ + { + "start": "2026-08-24", + "end": "2026-08-25", + "summary": "Home", + "description": "test event", + "location": "Test Cases", + } + ] + } + } + + # Query events via calendar.get_events action on primary calendar entity + primary_response = await hass.services.async_call( + "calendar", + "get_events", + { + "start_date_time": "2026-08-24T00:00:00Z", + "end_date_time": "2026-08-26T00:00:00Z", + }, + target={"entity_id": "calendar.primary"}, + blocking=True, + return_response=True, + ) + # The primary calendar filters out transparent events because ignore_availability is False + assert primary_response == {"calendar.primary": {"events": []}} + + @pytest.mark.parametrize("calendar_is_primary", [False]) async def test_no_working_location_entity( hass: HomeAssistant, diff --git a/tests/components/google_health/test_config_flow.py b/tests/components/google_health/test_config_flow.py index 63fe0956bd32fa..2b6d3044082888 100644 --- a/tests/components/google_health/test_config_flow.py +++ b/tests/components/google_health/test_config_flow.py @@ -5,7 +5,8 @@ from google_health_api.const import HealthApiScope from google_health_api.exceptions import ( GoogleHealthApiError, - HealthApiForbiddenException, + HealthApiScopeInsufficientException, + HealthApiServiceDisabledException, ) from google_health_api.model import Identity import pytest @@ -243,8 +244,8 @@ async def test_config_flow_api_not_enabled( mock_google_health_client: AsyncMock, ) -> None: """Test config flow aborts if the Google Health API is not enabled.""" - mock_google_health_client.get_identity.side_effect = HealthApiForbiddenException( - "Forbidden" + mock_google_health_client.get_identity.side_effect = ( + HealthApiServiceDisabledException ) result = await hass.config_entries.flow.async_init( @@ -280,6 +281,50 @@ async def test_config_flow_api_not_enabled( } +@pytest.mark.usefixtures( + "current_request_with_host", "mock_setup_entry", "setup_credentials" +) +async def test_config_flow_scope_insufficient( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_google_health_client: AsyncMock, +) -> None: + """Test config flow aborts if the OAuth token has insufficient scope.""" + mock_google_health_client.get_identity.side_effect = ( + HealthApiScopeInsufficientException + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": " ".join(OAUTH_SCOPES), + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_profile_scope" + + @pytest.mark.usefixtures( "current_request_with_host", "mock_setup_entry", "setup_credentials" ) @@ -321,7 +366,7 @@ async def test_config_flow_missing_health_user_id( result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" + assert result["reason"] == "missing_profile_scope" @pytest.mark.usefixtures( diff --git a/tests/components/homeassistant/test_llm.py b/tests/components/homeassistant/test_llm.py index 241954b749c30c..88c681c8afc8b7 100644 --- a/tests/components/homeassistant/test_llm.py +++ b/tests/components/homeassistant/test_llm.py @@ -44,10 +44,10 @@ def _llm_context() -> llm.LLMContext: async def test_live_context_always_offered(hass: HomeAssistant) -> None: - """Test GetLiveContext is offered even when nothing is exposed.""" + """Test homeassistant__GetLiveContext is offered even when nothing is exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) result = await llm_component.async_get_tools(hass, _llm_context(), "assist") - assert "GetLiveContext" in [tool.name for tool in result.tools] + assert "homeassistant__GetLiveContext" in [tool.name for tool in result.tools] async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: @@ -72,27 +72,32 @@ async def test_prompt_no_entities(hass: HomeAssistant) -> None: async def test_get_live_context_no_exposed_entities(hass: HomeAssistant) -> None: - """Test GetLiveContext reports an error when nothing is exposed.""" + """Test homeassistant__GetLiveContext reports an error when nothing is exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) llm_context = _llm_context() result = await llm_component.async_get_tools(hass, llm_context, "assist") - tool = next(tool for tool in result.tools if tool.name == "GetLiveContext") + tool = next( + tool for tool in result.tools if tool.name == "homeassistant__GetLiveContext" + ) response = await tool.async_call( - hass, llm.ToolInput("GetLiveContext", {}), llm_context + hass, llm.ToolInput("homeassistant__GetLiveContext", {}), llm_context ) assert response == {"success": False, "error": ha_llm.NO_ENTITIES_PROMPT} async def test_get_live_context_tool(hass: HomeAssistant) -> None: - """Test GetLiveContext returns exposed entity state.""" + """Test homeassistant__GetLiveContext returns exposed entity state.""" llm_context = _llm_context() result = await llm_component.async_get_tools(hass, llm_context, "assist") - tool = next((tool for tool in result.tools if tool.name == "GetLiveContext"), None) + tool = next( + (tool for tool in result.tools if tool.name == "homeassistant__GetLiveContext"), + None, + ) assert tool is not None response = await tool.async_call( - hass, llm.ToolInput("GetLiveContext", {}), llm_context + hass, llm.ToolInput("homeassistant__GetLiveContext", {}), llm_context ) assert response["success"] is True assert "Kitchen Light" in response["result"] @@ -158,7 +163,7 @@ async def test_get_live_context_tool_filter( entity_registry: er.EntityRegistry, area_registry: ar.AreaRegistry, ) -> None: - """Test the filter parameters of the GetLiveContext tool.""" + """Test the filter parameters of the homeassistant__GetLiveContext tool.""" # The autouse fixture exposes light.kitchen; drop it for a clean entity set. async_expose_entity(hass, "conversation", ENTITY_ID, False) assert await async_setup_component(hass, "intent", {}) @@ -254,11 +259,11 @@ async def test_get_live_context_tool_filter( await hass.async_block_till_done() tools = await llm_component.async_get_tools(hass, llm_context, "assist") - tool = next(t for t in tools.tools if t.name == "GetLiveContext") + tool = next(t for t in tools.tools if t.name == "homeassistant__GetLiveContext") async def _get_live_context(tool_args: dict) -> dict: return await tool.async_call( - hass, llm.ToolInput("GetLiveContext", tool_args), llm_context + hass, llm.ToolInput("homeassistant__GetLiveContext", tool_args), llm_context ) # Filter by area and domain (example 1) @@ -400,9 +405,9 @@ async def _get_live_context(tool_args: dict) -> dict: async def test_get_live_context_schema( hass: HomeAssistant, snapshot: SnapshotAssertion ) -> None: - """Test that GetLiveContext tool parameters convert to a sane OpenAPI schema.""" + """Test that homeassistant__GetLiveContext tool parameters convert to a sane OpenAPI schema.""" result = await llm_component.async_get_tools(hass, _llm_context(), "assist") - tool = next(t for t in result.tools if t.name == "GetLiveContext") + tool = next(t for t in result.tools if t.name == "homeassistant__GetLiveContext") api = await llm.async_get_api(hass, "assist", _llm_context()) schema = convert(tool.parameters, custom_serializer=api.custom_serializer) diff --git a/tests/components/humidifier/test_llm.py b/tests/components/humidifier/test_llm.py index e83d932bedbd9f..eb6b7974f3a950 100644 --- a/tests/components/humidifier/test_llm.py +++ b/tests/components/humidifier/test_llm.py @@ -10,7 +10,7 @@ from homeassistant.setup import async_setup_component ENTITY_ID = "humidifier.test" -INTENTS = {"HassHumidifierMode", "HassHumidifierSetpoint"} +TOOL_NAMES = {"humidifier__HassHumidifierMode", "humidifier__HassHumidifierSetpoint"} @pytest.fixture(autouse=True) @@ -44,13 +44,13 @@ async def _tool_names(hass: HomeAssistant) -> set[str]: async def test_intent_tool_exposed(hass: HomeAssistant) -> None: """Test the intent tool is offered for an exposed humidifier entity.""" - assert await _tool_names(hass) >= INTENTS + assert await _tool_names(hass) >= TOOL_NAMES async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: """Test the intent tool is hidden when no humidifier entity is exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) - assert not INTENTS & await _tool_names(hass) + assert not TOOL_NAMES & await _tool_names(hass) assert humidifier_llm.async_get_tools(hass, _llm_context(), "assist") is None diff --git a/tests/components/intent/test_llm.py b/tests/components/intent/test_llm.py index e3b5a79764b47e..8a160e153285bc 100644 --- a/tests/components/intent/test_llm.py +++ b/tests/components/intent/test_llm.py @@ -44,13 +44,13 @@ async def _tool_names(hass: HomeAssistant) -> set[str]: async def test_generic_intents_exposed(hass: HomeAssistant) -> None: """Test the always-on generic intents are exposed.""" names = await _tool_names(hass) - assert "HassTurnOn" in names - assert "HassTurnOff" in names + assert "intent__HassTurnOn" in names + assert "intent__HassTurnOff" in names async def test_timer_intents_require_timer_device(hass: HomeAssistant) -> None: """Test timer intents are not exposed without a timer-capable device.""" - assert "HassStartTimer" not in await _tool_names(hass) + assert "intent__HassStartTimer" not in await _tool_names(hass) async def test_timer_intents_offered_for_timer_device(hass: HomeAssistant) -> None: @@ -66,16 +66,16 @@ def handle_timer(*args: object) -> None: hass, _llm_context(device_id="test_device"), "assist" ) names = {tool.name for tool in result.tools} - assert "HassStartTimer" in names - assert "HassTimerStatus" in names + assert "intent__HassStartTimer" in names + assert "intent__HassTimerStatus" in names async def test_set_position_requires_exposed_cover(hass: HomeAssistant) -> None: - """Test HassSetPosition is only exposed when a cover/valve is exposed.""" - assert "HassSetPosition" in await _tool_names(hass) + """Test intent__HassSetPosition is only exposed when a cover/valve is exposed.""" + assert "intent__HassSetPosition" in await _tool_names(hass) async_expose_entity(hass, "conversation", COVER_ENTITY_ID, False) - assert "HassSetPosition" not in await _tool_names(hass) + assert "intent__HassSetPosition" not in await _tool_names(hass) async def test_prompt_includes_device_control(hass: HomeAssistant) -> None: diff --git a/tests/components/intent_script/test_llm.py b/tests/components/intent_script/test_llm.py index c72d479cee386a..322549e5cd744e 100644 --- a/tests/components/intent_script/test_llm.py +++ b/tests/components/intent_script/test_llm.py @@ -66,17 +66,17 @@ async def test_intent_scripts_exposed(hass: HomeAssistant) -> None: """Test intent scripts are exposed as LLM tools with slugified names.""" names = await _tool_names(hass) # The user-provided "Tell a joke" name is slugified into a valid tool name. - assert "Tell_a_joke" in names - assert "LightAction" in names + assert "intent_script__Tell_a_joke" in names + assert "intent_script__LightAction" in names async def test_intent_script_platform_filtered(hass: HomeAssistant) -> None: """Test a platform-restricted intent script requires an exposed entity.""" async_expose_entity(hass, "conversation", LIGHT_ENTITY_ID, False) names = await _tool_names(hass) - assert "LightAction" not in names + assert "intent_script__LightAction" not in names # Unrestricted intent scripts stay exposed. - assert "Tell_a_joke" in names + assert "intent_script__Tell_a_joke" in names async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index 243fd7dc10a351..57f63baef71aaa 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -38,22 +38,53 @@ async def test_binary_expose(hass: HomeAssistant, knx: KNXTestKit) -> None: }, ) - # Change state to on + # First known state initializes the expose without sending. hass.states.async_set(entity_id, "on", {}) await hass.async_block_till_done() - await knx.assert_write("1/1/8", True) + await knx.assert_no_telegram() - # Change attribute; keep state + # Change attribute; keep state. hass.states.async_set(entity_id, "on", {"brightness": 180}) await hass.async_block_till_done() await knx.assert_no_telegram() - # Change attribute and state + # Change state. hass.states.async_set(entity_id, "off", {"brightness": 0}) await hass.async_block_till_done() await knx.assert_write("1/1/8", False) +async def test_binary_expose_does_not_send_initial_state( + hass: HomeAssistant, + knx: KNXTestKit, +) -> None: + """Test only the initial state is not exposed to KNX.""" + entity_id = "binary_sensor.fake" + await knx.setup_integration( + { + CONF_KNX_EXPOSE: { + CONF_TYPE: "binary", + KNX_ADDRESS: "1/1/8", + CONF_ENTITY_ID: entity_id, + } + }, + ) + + # The first known value initializes the exposure without sending. + hass.states.async_set(entity_id, "on", {}) + await hass.async_block_till_done() + await knx.assert_no_telegram() + + # The initialized value is available for GroupValueRead responses. + await knx.receive_read("1/1/8") + await knx.assert_response("1/1/8", True) + + # A subsequent state change is exposed normally. + hass.states.async_set(entity_id, "off", {}) + await hass.async_block_till_done() + await knx.assert_write("1/1/8", False) + + async def test_expose_attribute(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test an expose to only send telegrams on attribute change.""" entity_id = "fake.entity" @@ -78,10 +109,10 @@ async def test_expose_attribute(hass: HomeAssistant, knx: KNXTestKit) -> None: await hass.async_block_till_done() await knx.assert_telegram_count(0) - # Change attribute; keep state + # First known attribute value initializes the expose without sending. hass.states.async_set(entity_id, "on", {attribute: 1}) await hass.async_block_till_done() - await knx.assert_write("1/1/8", (1,)) + await knx.assert_no_telegram() # Change attribute below resolution of DPT; expect no telegram hass.states.async_set(entity_id, "on", {attribute: 1.2}) @@ -148,7 +179,7 @@ async def test_expose_attribute_with_default( # Change state to "on"; no attribute -> default hass.states.async_set(entity_id, "on", {}) await hass.async_block_till_done() - await knx.assert_write("1/1/8", (0,)) + await knx.assert_no_telegram() # Change attribute; keep state hass.states.async_set(entity_id, "on", {attribute: 1}) @@ -231,6 +262,10 @@ async def test_expose_cooldown( """Test an expose with cooldown.""" cooldown_time = 2 entity_id = "fake.entity" + + hass.states.async_set(entity_id, "0", {}) + await hass.async_block_till_done() + await knx.setup_integration( { CONF_KNX_EXPOSE: { @@ -241,10 +276,12 @@ async def test_expose_cooldown( } }, ) + # Change state to 1 hass.states.async_set(entity_id, "1", {}) await hass.async_block_till_done() await knx.assert_write("1/1/8", (1,)) + # Change state to 2 - skip because of cooldown hass.states.async_set(entity_id, "2", {}) await hass.async_block_till_done() @@ -254,6 +291,7 @@ async def test_expose_cooldown( hass.states.async_set(entity_id, "3", {}) await hass.async_block_till_done() await knx.assert_no_telegram() + # Wait for cooldown to pass freezer.tick(timedelta(seconds=cooldown_time)) async_fire_time_changed(hass) @@ -262,9 +300,11 @@ async def test_expose_cooldown( async def test_expose_periodic_send( - hass: HomeAssistant, knx: KNXTestKit, freezer: FrozenDateTimeFactory + hass: HomeAssistant, + knx: KNXTestKit, + freezer: FrozenDateTimeFactory, ) -> None: - """Test an expose with periodic send.""" + """Test an initialized expose with periodic send.""" entity_id = "fake.entity" await knx.setup_integration( { @@ -276,11 +316,13 @@ async def test_expose_periodic_send( } }, ) - # Initialize state + + # Initial value is adopted without sending. hass.states.async_set(entity_id, "15", {}) await hass.async_block_till_done() - await knx.assert_write("1/1/8", (15,)) - # Wait for time to pass + await knx.assert_no_telegram() + + # The initialized value is still picked up by periodic_send. freezer.tick(timedelta(seconds=60)) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -295,6 +337,10 @@ async def test_expose_value_template( attribute = "brightness" binary_address = "1/1/1" percent_address = "2/2/2" + + hass.states.async_set(entity_id, "off", {attribute: 255}) + await hass.async_block_till_done() + await knx.setup_integration( { CONF_KNX_EXPOSE: [ @@ -397,6 +443,9 @@ async def test_ui_expose_create_and_update( await knx.setup_integration() ws_client = await hass_ws_client(hass) + hass.states.async_set(ENTITY_ID, "off", {"brightness": 30}) + await hass.async_block_till_done() + await ws_client.send_json_auto_id( { "type": "knx/update_expose", @@ -445,9 +494,12 @@ async def test_ui_expose_create_and_update( hass.states.async_set(ENTITY_ID, "on", {"brightness": 50}) await hass.async_block_till_done() - await knx.assert_write(GROUP_ADDRESS_1, True) await knx.assert_write(GROUP_ADDRESS_2, (128,)) + hass.states.async_set(ENTITY_ID, "off", {"brightness": 50}) + await hass.async_block_till_done() + await knx.assert_write(GROUP_ADDRESS_1, False) + async def test_ui_expose_with_options( hass: HomeAssistant, @@ -463,6 +515,9 @@ async def test_ui_expose_with_options( await knx.setup_integration() ws_client = await hass_ws_client(hass) + hass.states.async_set(ENTITY_ID, "on", {"brightness": 100}) + await hass.async_block_till_done() + await ws_client.send_json_auto_id( { "type": "knx/update_expose", @@ -486,11 +541,11 @@ async def test_ui_expose_with_options( assert res["success"], res assert res["result"]["success"] is True, res["result"] - # Change attribute to None - 1 because of value template + # Change attribute to 1 - because of value template hass.states.async_set(ENTITY_ID, "on", {"brightness": 10}) await hass.async_block_till_done() await knx.assert_write(GROUP_ADDRESS_1, (1,)) - # Change attribute to 2 - skip because of cooldown + # Change attribute to 50 - skip because of cooldown hass.states.async_set(ENTITY_ID, "on", {"brightness": 100}) await hass.async_block_till_done() await knx.assert_no_telegram() diff --git a/tests/components/lawn_mower/test_llm.py b/tests/components/lawn_mower/test_llm.py index 204b07a4c51adb..a16946ca4c7653 100644 --- a/tests/components/lawn_mower/test_llm.py +++ b/tests/components/lawn_mower/test_llm.py @@ -10,7 +10,7 @@ from homeassistant.setup import async_setup_component ENTITY_ID = "lawn_mower.test" -INTENTS = {"HassLawnMowerDock", "HassLawnMowerStartMowing"} +TOOL_NAMES = {"lawn_mower__HassLawnMowerDock", "lawn_mower__HassLawnMowerStartMowing"} @pytest.fixture(autouse=True) @@ -44,13 +44,13 @@ async def _tool_names(hass: HomeAssistant) -> set[str]: async def test_intent_tool_exposed(hass: HomeAssistant) -> None: """Test the intent tool is offered for an exposed lawn_mower entity.""" - assert await _tool_names(hass) >= INTENTS + assert await _tool_names(hass) >= TOOL_NAMES async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: """Test the intent tool is hidden when no lawn_mower entity is exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) - assert not INTENTS & await _tool_names(hass) + assert not TOOL_NAMES & await _tool_names(hass) assert lawn_mower_llm.async_get_tools(hass, _llm_context(), "assist") is None diff --git a/tests/components/light/test_llm.py b/tests/components/light/test_llm.py index d346ce0a4ee402..3352e278260e1d 100644 --- a/tests/components/light/test_llm.py +++ b/tests/components/light/test_llm.py @@ -43,13 +43,13 @@ async def _tool_names(hass: HomeAssistant) -> set[str]: async def test_intent_tool_exposed(hass: HomeAssistant) -> None: """Test the intent tool is offered for an exposed light entity.""" - assert "HassLightSet" in await _tool_names(hass) + assert "light__HassLightSet" in await _tool_names(hass) async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: """Test the intent tool is hidden when no light entity is exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) - assert "HassLightSet" not in await _tool_names(hass) + assert "light__HassLightSet" not in await _tool_names(hass) assert light_llm.async_get_tools(hass, _llm_context(), "assist") is None diff --git a/tests/components/llama_cpp/test_conversation.py b/tests/components/llama_cpp/test_conversation.py index e35445e506168b..b9e7c970aba9c6 100644 --- a/tests/components/llama_cpp/test_conversation.py +++ b/tests/components/llama_cpp/test_conversation.py @@ -1,6 +1,7 @@ """Tests for the llama.cpp conversation platform.""" from collections.abc import AsyncGenerator, Generator +import datetime from typing import Any from unittest.mock import AsyncMock, patch @@ -193,6 +194,102 @@ def completion_result( assert mock_chat_log.content[1:] == snapshot +@pytest.mark.parametrize(("config_entry_options"), [ASSIST_OPTIONS]) +async def test_function_call_with_datetime_tool_results( + hass: HomeAssistant, + mock_chat_log: MockChatLog, + mock_config_entry: MockConfigEntry, +) -> None: + """Test function call where tool result contains time/date/datetime objects.""" + mock_chat_log.mock_tool_results( + { + "call_call_1": { + "speech_slots": { + "time": datetime.time(12, 0), + "date": datetime.date(2026, 8, 23), + "datetime": datetime.datetime(2026, 8, 23, 12, 0), + } + }, + } + ) + + def completion_result( + *args: Any, messages: list[dict[str, Any]] | list[Any], **kwargs: Any + ) -> ChatCompletion: + for message in messages: + role = message["role"] if isinstance(message, dict) else message.role + if role == "tool": + return ChatCompletion( + id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="I have successfully called the function with time", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + return ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="tool_calls", + index=0, + message=ChatCompletionMessage( + content=None, + role="assistant", + function_call=None, + tool_calls=[ + ChatCompletionMessageToolCall( + id="call_call_1", + function=Function( + arguments='{"param1":"call1"}', + name="test_tool", + ), + type="function", + ) + ], + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + side_effect=completion_result, + ): + result = await conversation.async_converse( + hass, + "Please call the test function", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.llama_cpp_conversation", + ) + + assert result.response.response_type is intent.IntentResponseType.ACTION_DONE + + @pytest.mark.parametrize(("config_entry_options"), [ASSIST_OPTIONS]) @pytest.mark.parametrize( ("tool_arguments"), diff --git a/tests/components/llm/test_init.py b/tests/components/llm/test_init.py index 561c2cc04e132f..94be815f295233 100644 --- a/tests/components/llm/test_init.py +++ b/tests/components/llm/test_init.py @@ -1,12 +1,13 @@ """Tests for the LLM integration.""" -from unittest.mock import Mock +import logging +from unittest.mock import Mock, patch import pytest from homeassistant.components.llm import DATA_PLATFORMS, LLMTools, async_get_tools from homeassistant.core import HomeAssistant -from homeassistant.helpers import llm +from homeassistant.helpers import frame, llm from homeassistant.setup import async_setup_component from homeassistant.util.json import JsonObjectType @@ -44,7 +45,10 @@ def llm_context() -> llm.LLMContext: def _mock_tools_platform( - hass: HomeAssistant, domain: str, tools: LLMTools | Exception | None + hass: HomeAssistant, + domain: str, + tools: LLMTools | Exception | None, + built_in: bool = True, ) -> Mock: """Register a mock /llm.py platform returning the given tools.""" if isinstance(tools, Exception): @@ -52,7 +56,9 @@ def _mock_tools_platform( else: async_get_tools = Mock(return_value=tools) hass.config.components.add(domain) - mock_platform(hass, f"{domain}.llm", Mock(async_get_tools=async_get_tools)) + mock_platform( + hass, f"{domain}.llm", Mock(async_get_tools=async_get_tools), built_in=built_in + ) return async_get_tools @@ -72,8 +78,8 @@ async def test_get_tools(hass: HomeAssistant, llm_context: llm.LLMContext) -> No assert await async_setup_component(hass, "llm", {}) result = await async_get_tools(hass, llm_context, "assist") - # The llm integration also exposes its own GetDateTime tool (domain "llm"). - assert [tool.name for tool in result.tools] == ["GetDateTime", "my_tool"] + # The llm integration also exposes its own llm__GetDateTime tool (domain "llm"). + assert [tool.name for tool in result.tools] == ["llm__GetDateTime", "my_tool"] assert result.prompt == "use my_tool wisely" platform_get_tools.assert_called_once_with(hass, llm_context, "assist") @@ -85,7 +91,7 @@ async def test_get_tools_empty( assert await async_setup_component(hass, "llm", {}) result = await async_get_tools(hass, llm_context, "assist") - assert [tool.name for tool in result.tools] == ["GetDateTime"] + assert [tool.name for tool in result.tools] == ["llm__GetDateTime"] assert result.prompt is None @@ -102,7 +108,11 @@ async def test_get_tools_merges_sorted( assert await async_setup_component(hass, "llm", {}) result = await async_get_tools(hass, llm_context, "assist") - assert [tool.name for tool in result.tools] == ["GetDateTime", "tool_a", "tool_b"] + assert [tool.name for tool in result.tools] == [ + "llm__GetDateTime", + "tool_a", + "tool_b", + ] assert result.prompt == "prompt a\nprompt b" @@ -117,7 +127,7 @@ async def test_get_tools_skips_none_platform( assert await async_setup_component(hass, "llm", {}) result = await async_get_tools(hass, llm_context, "assist") - assert [tool.name for tool in result.tools] == ["GetDateTime", "good_tool"] + assert [tool.name for tool in result.tools] == ["llm__GetDateTime", "good_tool"] assert result.prompt is None @@ -134,6 +144,61 @@ async def test_get_tools_isolates_failing_platform( assert await async_setup_component(hass, "llm", {}) result = await async_get_tools(hass, llm_context, "assist") - assert [tool.name for tool in result.tools] == ["GetDateTime", "good_tool"] + assert [tool.name for tool in result.tools] == ["llm__GetDateTime", "good_tool"] assert result.prompt == "prompt" assert "Error getting tools from LLM platform test_bad" in caplog.text + + +@pytest.mark.parametrize( + ("built_in", "expected_level", "expected_type"), + [(True, logging.ERROR, ""), (False, logging.WARNING, "custom ")], + ids=["core", "custom"], +) +async def test_get_tools_reports_unprefixed_tool_names( + hass: HomeAssistant, + llm_context: llm.LLMContext, + caplog: pytest.LogCaptureFixture, + built_in: bool, + expected_level: int, + expected_type: str, +) -> None: + """Test tools not prefixed with the offering domain are reported.""" + tools = [_StubTool("test__prefixed"), _StubTool("unprefixed")] + _mock_tools_platform(hass, "test", LLMTools(tools=tools), built_in=built_in) + + assert await async_setup_component(hass, "llm", {}) + + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + result = await async_get_tools(hass, llm_context, "assist") + + # The tools are still returned until the requirement starts to fail. + assert [tool.name for tool in result.tools] == [ + "llm__GetDateTime", + "test__prefixed", + "unprefixed", + ] + expected_message = ( + f"Detected that {expected_type}integration 'test' provides LLM tools that are " + "not prefixed with 'test__': unprefixed. This will stop working in Home " + "Assistant 2027.3" + ) + record = next( + record for record in caplog.records if expected_message in record.getMessage() + ) + assert record.levelno == expected_level + + +async def test_get_tools_prefixed_tool_names_not_reported( + hass: HomeAssistant, + llm_context: llm.LLMContext, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a platform prefixing all its tools is not reported.""" + _mock_tools_platform(hass, "test", LLMTools(tools=[_StubTool("test__tool")])) + + assert await async_setup_component(hass, "llm", {}) + + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + await async_get_tools(hass, llm_context, "assist") + + assert "not prefixed with 'test__'" not in caplog.text diff --git a/tests/components/llm/test_tools.py b/tests/components/llm/test_tools.py index cb0d31e3110434..66d856f6b91924 100644 --- a/tests/components/llm/test_tools.py +++ b/tests/components/llm/test_tools.py @@ -30,15 +30,17 @@ def _llm_context() -> llm.LLMContext: async def test_get_datetime_tool(hass: HomeAssistant) -> None: - """Test the GetDateTime tool is always offered and returns the current time.""" + """Test the llm__GetDateTime tool is always offered and returns the current time.""" llm_context = _llm_context() result = await llm_component.async_get_tools(hass, llm_context, "assist") - tool = next((tool for tool in result.tools if tool.name == "GetDateTime"), None) + tool = next( + (tool for tool in result.tools if tool.name == "llm__GetDateTime"), None + ) assert tool is not None with freeze_time("2025-09-17 13:00:00"): response = await tool.async_call( - hass, llm.ToolInput("GetDateTime", {}), llm_context + hass, llm.ToolInput("llm__GetDateTime", {}), llm_context ) assert response == { diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index d05640414faa11..4a85ae5adf70ee 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -385,8 +385,10 @@ async def test_mcp_tools_list( # Pick a single arbitrary tool and test that description and parameters # are converted correctly. - tool = next(iter(tool for tool in result.tools if tool.name == "HassTurnOn")) - assert tool.name == "HassTurnOn" + tool = next( + iter(tool for tool in result.tools if tool.name == "intent__HassTurnOn") + ) + assert tool.name == "intent__HassTurnOn" assert tool.description is not None assert tool.inputSchema assert tool.inputSchema.get("type") == "object" @@ -410,7 +412,7 @@ async def test_mcp_tool_call( async with mcp_client(hass, mcp_url, hass_supervisor_access_token) as session: result = await session.call_tool( - name="HassTurnOn", + name="intent__HassTurnOn", arguments={"name": "kitchen light"}, ) @@ -439,7 +441,7 @@ async def test_mcp_tool_call_failed( async with mcp_client(hass, mcp_url, hass_supervisor_access_token) as session: result = await session.call_tool( - name="HassTurnOn", + name="intent__HassTurnOn", arguments={"name": "backyard"}, ) diff --git a/tests/components/media_player/test_llm.py b/tests/components/media_player/test_llm.py index 7ca247a63af0fb..fcefaa14d9e365 100644 --- a/tests/components/media_player/test_llm.py +++ b/tests/components/media_player/test_llm.py @@ -10,16 +10,16 @@ from homeassistant.setup import async_setup_component ENTITY_ID = "media_player.test" -INTENTS = { - "HassMediaNext", - "HassMediaPause", - "HassMediaPlayerMute", - "HassMediaPlayerUnmute", - "HassMediaPrevious", - "HassMediaSearchAndPlay", - "HassMediaUnpause", - "HassSetVolume", - "HassSetVolumeRelative", +TOOL_NAMES = { + "media_player__HassMediaNext", + "media_player__HassMediaPause", + "media_player__HassMediaPlayerMute", + "media_player__HassMediaPlayerUnmute", + "media_player__HassMediaPrevious", + "media_player__HassMediaSearchAndPlay", + "media_player__HassMediaUnpause", + "media_player__HassSetVolume", + "media_player__HassSetVolumeRelative", } @@ -54,13 +54,13 @@ async def _tool_names(hass: HomeAssistant) -> set[str]: async def test_intent_tool_exposed(hass: HomeAssistant) -> None: """Test the intent tool is offered for an exposed media_player entity.""" - assert await _tool_names(hass) >= INTENTS + assert await _tool_names(hass) >= TOOL_NAMES async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: """Test the intent tool is hidden when no media_player entity is exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) - assert not INTENTS & await _tool_names(hass) + assert not TOOL_NAMES & await _tool_names(hass) assert media_player_llm.async_get_tools(hass, _llm_context(), "assist") is None diff --git a/tests/components/modbus/test_connection.py b/tests/components/modbus/test_connection.py index c41561af225836..c5df0db55dfb24 100644 --- a/tests/components/modbus/test_connection.py +++ b/tests/components/modbus/test_connection.py @@ -4,10 +4,12 @@ from unittest.mock import AsyncMock, patch from modbus_connection import ModbusSerialParams, ModbusTcpParams +from modbus_connection.tmodbus import ModbusConnection import pytest from homeassistant.components.modbus.connection import ( DATA_MODBUS_CONNECTIONS, + async_get_temporary_unit, async_get_unit, ) from homeassistant.config_entries import ConfigFlow @@ -188,3 +190,70 @@ async def test_reloading_an_entry_reopens_the_connection( [second] = hass.data[DATA_MODBUS_CONNECTIONS].values() assert second.connection is not first.connection + + +async def test_a_temporary_unit_closes_the_connection_on_exit( + hass: HomeAssistant, +) -> None: + """A config flow's hold ends with the context, not with a config entry.""" + with patch.object(ModbusConnection, "close") as close: + async with async_get_temporary_unit( + hass, ModbusTcpParams(host="1.2.3.4", port=502), 1 + ): + [shared] = hass.data[DATA_MODBUS_CONNECTIONS].values() + assert shared.consumers == 1 + + assert close.called + assert not hass.data[DATA_MODBUS_CONNECTIONS] + + +async def test_a_temporary_unit_releases_when_the_context_raises( + hass: HomeAssistant, +) -> None: + """A flow step failing must not leak the connection it probed over.""" + with patch.object(ModbusConnection, "close") as close, pytest.raises(ValueError): + async with async_get_temporary_unit( + hass, ModbusTcpParams(host="1.2.3.4", port=502), 1 + ): + raise ValueError + + assert close.called + assert not hass.data[DATA_MODBUS_CONNECTIONS] + + +async def test_a_temporary_unit_shares_a_connection_an_entry_holds( + hass: HomeAssistant, consumer: ConsumerFactory +) -> None: + """A flow probing a device an entry already talks to joins its connection. + + The connection outlives the flow because the entry still holds it. + """ + entry = consumer() + await hass.config_entries.async_setup(entry.entry_id) + params = ModbusTcpParams(host="1.2.3.4", port=502) + async_get_unit(hass, entry, params, 1) + [shared] = hass.data[DATA_MODBUS_CONNECTIONS].values() + + async with async_get_temporary_unit(hass, params, 2): + assert shared.consumers == 2 + + assert shared.consumers == 1 + assert hass.data[DATA_MODBUS_CONNECTIONS] + + +async def test_a_temporary_unit_cannot_clash_with_held_link_settings( + hass: HomeAssistant, consumer: ConsumerFactory +) -> None: + """A flow gets told about a link settings clash when entering the context.""" + entry = consumer() + await hass.config_entries.async_setup(entry.entry_id) + async_get_unit(hass, entry, ModbusTcpParams(host="1.2.3.4", port=502), 1) + + with pytest.raises(HomeAssistantError, match="different link settings"): + async with async_get_temporary_unit( + hass, ModbusTcpParams(host="1.2.3.4", port=502, framer="rtu"), 2 + ): + pass + + [shared] = hass.data[DATA_MODBUS_CONNECTIONS].values() + assert shared.consumers == 1 diff --git a/tests/components/open_router/test_conversation.py b/tests/components/open_router/test_conversation.py index 314e1eec3ce52c..5ec1064321584e 100644 --- a/tests/components/open_router/test_conversation.py +++ b/tests/components/open_router/test_conversation.py @@ -145,9 +145,9 @@ async def test_web_search_with_assist( assert {"type": "openrouter:web_search", "parameters": {"engine": "auto"}} in call[ "extra_body" ]["tools"] - # Ensure GetDateTime is in the tools list + # Ensure llm__GetDateTime is in the tools list assert any( - tool.get("function", {}).get("name") == "GetDateTime" + tool.get("function", {}).get("name") == "llm__GetDateTime" for tool in call["extra_body"]["tools"] ) diff --git a/tests/components/openevse/test_button.py b/tests/components/openevse/test_button.py index d995773ef87ed8..8f4ab4f74ae06f 100644 --- a/tests/components/openevse/test_button.py +++ b/tests/components/openevse/test_button.py @@ -6,6 +6,7 @@ from openevsehttp.exceptions import ( AuthenticationError, ParseJSONError, + UnknownError, UnsupportedFeature, ) import pytest @@ -76,41 +77,61 @@ async def test_press( @pytest.mark.parametrize( ("raised", "expected", "translation_key", "translation_placeholders"), [ - ( + pytest.param( AuthenticationError("bad creds"), ConfigEntryAuthFailed, "authentication_error", None, + id="auth_error", ), - ( + pytest.param( TimeoutError("timed out"), HomeAssistantError, "communication_error", None, + id="timeout_error", ), - ( + pytest.param( ServerTimeoutError("timed out"), HomeAssistantError, "communication_error", None, + id="server_timeout_error", ), - ( + pytest.param( ParseJSONError("bad json"), HomeAssistantError, "communication_error", None, + id="parse_json_error", ), - ( + pytest.param( UnsupportedFeature("old firmware"), HomeAssistantError, "unsupported_feature", None, + id="unsupported_feature", ), - ( + pytest.param( ContentTypeError(MagicMock(), (), message="bad content"), HomeAssistantError, "communication_error", None, + id="content_type_error", + ), + pytest.param( + UnknownError("unknown error"), + HomeAssistantError, + "communication_error", + None, + id="unknown_error", + ), + pytest.param( + RuntimeError("runtime error"), + HomeAssistantError, + "communication_error", + None, + id="runtime_error", ), ], ) diff --git a/tests/components/openevse/test_number.py b/tests/components/openevse/test_number.py index 09c518a3e375d7..2b4ae8446d511e 100644 --- a/tests/components/openevse/test_number.py +++ b/tests/components/openevse/test_number.py @@ -6,6 +6,7 @@ from openevsehttp.exceptions import ( AuthenticationError, ParseJSONError, + UnknownError, UnsupportedFeature, ) import pytest @@ -67,47 +68,68 @@ async def test_set_value( @pytest.mark.parametrize( ("raised", "expected", "translation_key", "translation_placeholders"), [ - ( + pytest.param( ValueError("out of range"), ServiceValidationError, "invalid_value", {"value": "32.0"}, + id="value_error", ), - ( + pytest.param( AuthenticationError("bad creds"), ConfigEntryAuthFailed, "authentication_error", None, + id="auth_error", ), - ( + pytest.param( TimeoutError("timed out"), HomeAssistantError, "communication_error", None, + id="timeout_error", ), - ( + pytest.param( ServerTimeoutError("timed out"), HomeAssistantError, "communication_error", None, + id="server_timeout_error", ), - ( + pytest.param( ParseJSONError("bad json"), HomeAssistantError, "communication_error", None, + id="parse_json_error", ), - ( + pytest.param( UnsupportedFeature("old firmware"), HomeAssistantError, "unsupported_feature", None, + id="unsupported_feature", ), - ( + pytest.param( ContentTypeError(MagicMock(), (), message="bad content"), HomeAssistantError, "communication_error", None, + id="content_type_error", + ), + pytest.param( + UnknownError("unknown error"), + HomeAssistantError, + "communication_error", + None, + id="unknown_error", + ), + pytest.param( + RuntimeError("runtime error"), + HomeAssistantError, + "communication_error", + None, + id="runtime_error", ), ], ) diff --git a/tests/components/openevse/test_switch.py b/tests/components/openevse/test_switch.py index a17c5e407c3c7d..337a52d06223d7 100644 --- a/tests/components/openevse/test_switch.py +++ b/tests/components/openevse/test_switch.py @@ -6,6 +6,7 @@ from openevsehttp.exceptions import ( AuthenticationError, ParseJSONError, + UnknownError, UnsupportedFeature, ) import pytest @@ -167,6 +168,20 @@ async def test_switch_turn_on_off( None, id="content_type_error", ), + pytest.param( + UnknownError("unknown error"), + HomeAssistantError, + "communication_error", + None, + id="unknown_error", + ), + pytest.param( + RuntimeError("runtime error"), + HomeAssistantError, + "communication_error", + None, + id="runtime_error", + ), ], ) async def test_switch_raises( diff --git a/tests/components/script/test_llm.py b/tests/components/script/test_llm.py index 9295d4c05553d5..107b5d332aeed2 100644 --- a/tests/components/script/test_llm.py +++ b/tests/components/script/test_llm.py @@ -58,7 +58,7 @@ async def test_script_tool_only_exposed(hass: HomeAssistant) -> None: """Test only exposed scripts get a tool.""" result = await llm_component.async_get_tools(hass, _llm_context(), "assist") names = [tool.name for tool in result.tools] - assert "test_script" in names + assert "script__test_script" in names assert "unexposed_script" not in names @@ -66,7 +66,7 @@ async def test_script_tool_not_exposed(hass: HomeAssistant) -> None: """Test no script tool is offered when the script is not exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) result = await llm_component.async_get_tools(hass, _llm_context(), "assist") - assert "test_script" not in [tool.name for tool in result.tools] + assert "script__test_script" not in [tool.name for tool in result.tools] assert script_llm.async_get_tools(hass, _llm_context(), "assist") is None @@ -79,10 +79,10 @@ async def test_script_tool_call(hass: HomeAssistant) -> None: """Test calling the exposed script through its tool.""" llm_context = _llm_context() result = await llm_component.async_get_tools(hass, llm_context, "assist") - tool = next(tool for tool in result.tools if tool.name == "test_script") + tool = next(tool for tool in result.tools if tool.name == "script__test_script") response = await tool.async_call( - hass, llm.ToolInput("test_script", {"beer": 1}), llm_context + hass, llm.ToolInput("script__test_script", {"beer": 1}), llm_context ) assert response == {"success": True, "result": {"drinks": 2}} @@ -91,7 +91,7 @@ async def test_script_tool_name_not_started_with_digit(hass: HomeAssistant) -> N """Test a script whose id starts with a digit gets a valid tool name.""" async_expose_entity(hass, "conversation", "script.123456", True) result = await llm_component.async_get_tools(hass, _llm_context(), "assist") - assert "_123456" in [tool.name for tool in result.tools] + assert "script__123456" in [tool.name for tool in result.tools] async def test_script_tool_description_includes_aliases( @@ -100,7 +100,7 @@ async def test_script_tool_description_includes_aliases( """Test the script tool description is extended with the entity aliases.""" entity_registry.async_update_entity(ENTITY_ID, aliases=["barkeep", "pour a drink"]) result = await llm_component.async_get_tools(hass, _llm_context(), "assist") - tool = next(tool for tool in result.tools if tool.name == "test_script") + tool = next(tool for tool in result.tools if tool.name == "script__test_script") assert tool.description == ( "This is a test script. Aliases: ['barkeep', 'pour a drink']" ) diff --git a/tests/components/todo/test_llm.py b/tests/components/todo/test_llm.py index e0a373634cf683..bb56b76d47b348 100644 --- a/tests/components/todo/test_llm.py +++ b/tests/components/todo/test_llm.py @@ -41,7 +41,7 @@ async def test_get_tools_no_exposed_todo(hass: HomeAssistant) -> None: """Test no todo tool is offered when no to-do list is exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) result = await llm_component.async_get_tools(hass, _llm_context(), "assist") - assert "todo_get_items" not in [tool.name for tool in result.tools] + assert "todo__get_items" not in [tool.name for tool in result.tools] assert todo_llm.async_get_tools(hass, _llm_context(), "assist") is None @@ -54,7 +54,7 @@ async def test_todo_get_items_tool(hass: HomeAssistant) -> None: """Test the todo get items tool is exposed and works via the platform.""" llm_context = _llm_context() result = await llm_component.async_get_tools(hass, llm_context, "assist") - tool = next((tool for tool in result.tools if tool.name == "todo_get_items"), None) + tool = next((tool for tool in result.tools if tool.name == "todo__get_items"), None) assert tool is not None assert tool.parameters.schema["todo_list"].container == ["Mock Todo List Name"] @@ -74,7 +74,7 @@ async def test_todo_get_items_tool(hass: HomeAssistant) -> None: result = await tool.async_call( hass, - llm.ToolInput("todo_get_items", {"todo_list": "Mock Todo List Name"}), + llm.ToolInput("todo__get_items", {"todo_list": "Mock Todo List Name"}), llm_context, ) @@ -101,7 +101,7 @@ async def test_todo_get_items_status_filter( """Test the status filter is translated into the service call.""" llm_context = _llm_context() result = await llm_component.async_get_tools(hass, llm_context, "assist") - tool = next(tool for tool in result.tools if tool.name == "todo_get_items") + tool = next(tool for tool in result.tools if tool.name == "todo__get_items") calls = async_mock_service( hass, @@ -113,7 +113,7 @@ async def test_todo_get_items_status_filter( await tool.async_call( hass, llm.ToolInput( - "todo_get_items", {"todo_list": "Mock Todo List Name", "status": status} + "todo__get_items", {"todo_list": "Mock Todo List Name", "status": status} ), llm_context, ) @@ -124,6 +124,6 @@ async def test_todo_list_intents_exposed(hass: HomeAssistant) -> None: """Test the todo list intents are exposed as tools when a list is exposed.""" result = await llm_component.async_get_tools(hass, _llm_context(), "assist") names = {tool.name for tool in result.tools} - assert "HassListAddItem" in names - assert "HassListCompleteItem" in names - assert "HassListRemoveItem" in names + assert "todo__HassListAddItem" in names + assert "todo__HassListCompleteItem" in names + assert "todo__HassListRemoveItem" in names diff --git a/tests/components/tonewinner/__init__.py b/tests/components/tonewinner/__init__.py new file mode 100644 index 00000000000000..89ad04d56f3a89 --- /dev/null +++ b/tests/components/tonewinner/__init__.py @@ -0,0 +1 @@ +"""Tests for the Tonewinner AT-500 integration.""" diff --git a/tests/components/tonewinner/conftest.py b/tests/components/tonewinner/conftest.py new file mode 100644 index 00000000000000..ae6f2224036e4e --- /dev/null +++ b/tests/components/tonewinner/conftest.py @@ -0,0 +1,70 @@ +"""Common fixtures for the Tonewinner tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch + +import pytest + +from homeassistant.components.tonewinner.const import CONF_SERIAL_PORT, DOMAIN +from homeassistant.const import CONF_MODEL + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SERIAL_PORT: "/dev/ttyUSB0", + CONF_MODEL: "AT-500", + }, + entry_id="test_entry_id", + title="AT-500", + ) + + +@pytest.fixture +def mock_receiver() -> MagicMock: + """Return a mock TonewinnerReceiver.""" + receiver = MagicMock() + receiver.connected = True + receiver.state.power = False + receiver.state.volume = 50.0 + receiver.state.mute = False + receiver.state.source_name = None + receiver.state.audio_source = None + receiver.state.sound_mode_label = None + type(receiver).state = PropertyMock(return_value=receiver.state) + receiver.connect = AsyncMock() + receiver.disconnect = AsyncMock() + receiver.query_state = AsyncMock() + receiver.query_source = AsyncMock() + receiver.query_info = AsyncMock(return_value=None) + receiver.power_on = AsyncMock() + receiver.power_off = AsyncMock() + receiver.set_volume = AsyncMock() + receiver.volume_up = AsyncMock() + receiver.volume_down = AsyncMock() + receiver.mute_on = AsyncMock() + receiver.mute_off = AsyncMock() + receiver.select_source = AsyncMock() + receiver.select_sound_mode = AsyncMock() + receiver.subscribe = MagicMock(return_value=lambda: None) + return receiver + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return ["media_player"] + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.tonewinner.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/tonewinner/test_config_flow.py b/tests/components/tonewinner/test_config_flow.py new file mode 100644 index 00000000000000..60fa6e6191d5fe --- /dev/null +++ b/tests/components/tonewinner/test_config_flow.py @@ -0,0 +1,408 @@ +"""Test the Tonewinner config flow.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant import config_entries +from homeassistant.components.tonewinner.const import CONF_SERIAL_PORT, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_MODEL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +def _mock_receiver(model: str | None = "AT-500") -> MagicMock: + """Return a mock receiver that answers info queries.""" + mock_receiver = MagicMock() + mock_receiver.connect = AsyncMock() + mock_receiver.disconnect = AsyncMock() + mock_receiver.query_info = AsyncMock(return_value=MagicMock(model=model)) + return mock_receiver + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form, probe the receiver and can successfully set up.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + mock_receiver = _mock_receiver() + + with patch( + "homeassistant.components.tonewinner.config_flow.TonewinnerReceiver", + return_value=mock_receiver, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SERIAL_PORT: "/dev/ttyUSB0"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "AT-500" + assert result["data"] == { + CONF_SERIAL_PORT: "/dev/ttyUSB0", + CONF_MODEL: "AT-500", + } + assert len(mock_setup_entry.mock_calls) == 1 + mock_receiver.connect.assert_awaited_once() + mock_receiver.query_info.assert_awaited_once() + mock_receiver.disconnect.assert_awaited_once() + + +async def test_form_unknown_model( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test setup falls back to a generic title when no model is reported.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.tonewinner.config_flow.TonewinnerReceiver", + return_value=_mock_receiver(model=None), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SERIAL_PORT: "/dev/ttyUSB0"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Tonewinner" + assert result["data"] == {CONF_SERIAL_PORT: "/dev/ttyUSB0"} + + +async def test_form_duplicate_serial_port(hass: HomeAssistant) -> None: + """Test that configuring an already configured serial port aborts.""" + MockConfigEntry( + domain=DOMAIN, + data={CONF_SERIAL_PORT: "/dev/ttyUSB0", CONF_MODEL: "AT-500"}, + entry_id="existing_entry_id", + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.tonewinner.config_flow.TonewinnerReceiver" + ) as mock_receiver_cls: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SERIAL_PORT: "/dev/ttyUSB0"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + # The port is held by the existing entry; probing it would just fail. + mock_receiver_cls.assert_not_called() + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_receiver = MagicMock() + mock_receiver.connect = AsyncMock(side_effect=OSError("Permission denied")) + mock_receiver.disconnect = AsyncMock() + + with patch( + "homeassistant.components.tonewinner.config_flow.TonewinnerReceiver", + return_value=mock_receiver, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SERIAL_PORT: "/dev/ttyUSB0"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # Test recovery from error + with patch( + "homeassistant.components.tonewinner.config_flow.TonewinnerReceiver", + return_value=_mock_receiver(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SERIAL_PORT: "/dev/ttyUSB0"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_reconfigure(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test reconfiguring to a port reporting a different model updates data and title.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_SERIAL_PORT: "/dev/ttyUSB0", CONF_MODEL: "AT-500"}, + entry_id="test_entry_id", + title="AT-500", + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry.runtime_data = _mock_receiver(model=None) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": mock_config_entry.entry_id, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + with patch( + "homeassistant.components.tonewinner.config_flow.TonewinnerReceiver", + return_value=_mock_receiver(model="AT-300"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SERIAL_PORT: "/dev/ttyUSB1"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_SERIAL_PORT] == "/dev/ttyUSB1" + assert mock_config_entry.data[CONF_MODEL] == "AT-300" + assert mock_config_entry.title == "AT-300" + + +async def test_reconfigure_same_port( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test reconfiguring with an unchanged port releases and re-probes it.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_SERIAL_PORT: "/dev/ttyUSB0", CONF_MODEL: "AT-500"}, + entry_id="test_entry_id", + title="AT-500", + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry.mock_state(hass, ConfigEntryState.LOADED) + mock_config_entry.runtime_data = _mock_receiver(model=None) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": mock_config_entry.entry_id, + }, + ) + + with ( + patch( + "homeassistant.components.tonewinner.config_flow.TonewinnerReceiver", + return_value=_mock_receiver(model="AT-300"), + ), + patch( + "homeassistant.config_entries.ConfigEntries.async_unload", + new_callable=AsyncMock, + return_value=True, + ) as mock_unload, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SERIAL_PORT: "/dev/ttyUSB0"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + # The port was released so a swapped-in device could be identified. + mock_unload.assert_awaited_once_with(mock_config_entry.entry_id) + assert mock_config_entry.data == { + CONF_SERIAL_PORT: "/dev/ttyUSB0", + CONF_MODEL: "AT-300", + } + assert mock_config_entry.title == "AT-300" + + +async def test_reconfigure_unload_fails(hass: HomeAssistant) -> None: + """Test reconfigure aborts when the loaded entry cannot be unloaded.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_SERIAL_PORT: "/dev/ttyUSB0", CONF_MODEL: "AT-500"}, + entry_id="test_entry_id", + title="AT-500", + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry.mock_state(hass, ConfigEntryState.LOADED) + mock_config_entry.runtime_data = _mock_receiver(model=None) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": mock_config_entry.entry_id, + }, + ) + with ( + patch( + "homeassistant.components.tonewinner.config_flow.TonewinnerReceiver" + ) as mock_receiver_cls, + patch( + "homeassistant.config_entries.ConfigEntries.async_unload", + new_callable=AsyncMock, + return_value=False, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SERIAL_PORT: "/dev/ttyUSB0"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_unload_failed" + # Probing must not race a serial client that is still connected. + mock_receiver_cls.assert_not_called() + + +async def test_reconfigure_port_used_by_other_entry(hass: HomeAssistant) -> None: + """Test reconfiguring onto a port owned by another entry aborts.""" + other_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_SERIAL_PORT: "/dev/ttyUSB1", CONF_MODEL: "AT-500"}, + entry_id="other_entry_id", + ) + other_entry.add_to_hass(hass) + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_SERIAL_PORT: "/dev/ttyUSB0", CONF_MODEL: "AT-500"}, + entry_id="test_entry_id", + title="AT-500", + ) + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": mock_config_entry.entry_id, + }, + ) + + with patch( + "homeassistant.components.tonewinner.config_flow.TonewinnerReceiver", + return_value=_mock_receiver(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SERIAL_PORT: "/dev/ttyUSB1"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reconfigure_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test reconfigure shows an error, restores service and recovers after.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_SERIAL_PORT: "/dev/ttyUSB0", CONF_MODEL: "AT-500"}, + entry_id="test_entry_id", + title="AT-500", + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry.mock_state(hass, ConfigEntryState.LOADED) + mock_config_entry.runtime_data = _mock_receiver(model=None) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": mock_config_entry.entry_id, + }, + ) + + mock_receiver = MagicMock() + mock_receiver.connect = AsyncMock(side_effect=OSError("Permission denied")) + mock_receiver.disconnect = AsyncMock() + + with ( + patch( + "homeassistant.components.tonewinner.config_flow.TonewinnerReceiver", + return_value=mock_receiver, + ), + patch( + "homeassistant.config_entries.ConfigEntries.async_unload", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "homeassistant.config_entries.ConfigEntries.async_setup", + new_callable=AsyncMock, + ) as mock_setup, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SERIAL_PORT: "/dev/ttyUSB1"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": "cannot_connect"} + # The previous configuration is set up again so the receiver keeps working. + mock_setup.assert_awaited_once_with(mock_config_entry.entry_id) + + with patch( + "homeassistant.components.tonewinner.config_flow.TonewinnerReceiver", + return_value=_mock_receiver(model="AT-500"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SERIAL_PORT: "/dev/ttyUSB1"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_unknown_model( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test reconfiguring to a silent receiver drops the stale model and title.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_SERIAL_PORT: "/dev/ttyUSB0", CONF_MODEL: "AT-500"}, + entry_id="test_entry_id", + title="AT-500", + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry.runtime_data = _mock_receiver(model=None) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": mock_config_entry.entry_id, + }, + ) + + with patch( + "homeassistant.components.tonewinner.config_flow.TonewinnerReceiver", + return_value=_mock_receiver(model=None), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SERIAL_PORT: "/dev/ttyUSB1"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == {CONF_SERIAL_PORT: "/dev/ttyUSB1"} + assert mock_config_entry.title == "Tonewinner" diff --git a/tests/components/tonewinner/test_init.py b/tests/components/tonewinner/test_init.py new file mode 100644 index 00000000000000..a5b1891a634314 --- /dev/null +++ b/tests/components/tonewinner/test_init.py @@ -0,0 +1,174 @@ +"""Test the Tonewinner integration setup.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant.components.tonewinner.const import CONF_SERIAL_PORT, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_MODEL +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test setting up the integration.""" + mock_config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.tonewinner.TonewinnerReceiver", + return_value=mock_receiver, + ), + patch( + "homeassistant.config_entries.ConfigEntries.async_forward_entry_setups", + return_value=True, + ), + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_config_entry.runtime_data is mock_receiver + mock_receiver.connect.assert_awaited_once() + mock_receiver.query_state.assert_awaited_once() + mock_receiver.disconnect.assert_not_awaited() + + +async def test_setup_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test a failed connection raises ConfigEntryNotReady and cleans up.""" + mock_config_entry.add_to_hass(hass) + mock_receiver.connect.side_effect = OSError("Permission denied") + + with ( + patch( + "homeassistant.components.tonewinner.TonewinnerReceiver", + return_value=mock_receiver, + ), + patch( + "homeassistant.config_entries.ConfigEntries.async_forward_entry_setups", + return_value=True, + ), + ): + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + mock_receiver.disconnect.assert_awaited_once() + + +async def test_setup_entry_multiple_times( + hass: HomeAssistant, + mock_receiver: MagicMock, +) -> None: + """Test that setting up multiple entries doesn't conflict.""" + entry1 = MockConfigEntry( + domain=DOMAIN, + data={CONF_SERIAL_PORT: "/dev/ttyUSB0", CONF_MODEL: "AT-500"}, + entry_id="test_entry_id_1", + title="AT-500", + ) + entry2 = MockConfigEntry( + domain=DOMAIN, + data={CONF_SERIAL_PORT: "/dev/ttyUSB1", CONF_MODEL: "AT-500"}, + entry_id="test_entry_id_2", + title="AT-500", + ) + + entry1.add_to_hass(hass) + entry2.add_to_hass(hass) + + mock_receiver2 = MagicMock() + mock_receiver2.connect = AsyncMock() + mock_receiver2.query_state = AsyncMock() + mock_receiver2.disconnect = AsyncMock() + + def receiver_factory(*args: str, **kwargs: int) -> MagicMock: + if args[0] == "/dev/ttyUSB1": + return mock_receiver2 + return mock_receiver + + with ( + patch( + "homeassistant.components.tonewinner.TonewinnerReceiver", + side_effect=receiver_factory, + ), + patch( + "homeassistant.config_entries.ConfigEntries.async_forward_entry_setups", + return_value=True, + ), + ): + # Setting up one entry loads every entry of the integration + assert await hass.config_entries.async_setup(entry1.entry_id) + await hass.async_block_till_done() + + assert entry1.state is ConfigEntryState.LOADED + assert entry2.state is ConfigEntryState.LOADED + + assert entry1.runtime_data is mock_receiver + assert entry2.runtime_data is mock_receiver2 + + +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test unloading the integration disconnects the receiver.""" + mock_config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.tonewinner.TonewinnerReceiver", + return_value=mock_receiver, + ), + patch( + "homeassistant.config_entries.ConfigEntries.async_forward_entry_setups", + return_value=True, + ), + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + with patch( + "homeassistant.config_entries.ConfigEntries.async_unload_platforms", + return_value=True, + ): + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + + mock_receiver.disconnect.assert_awaited_once() + + +async def test_unload_entry_platform_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test unload keeps the receiver connected when platforms fail to unload.""" + mock_config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.tonewinner.TonewinnerReceiver", + return_value=mock_receiver, + ), + patch( + "homeassistant.config_entries.ConfigEntries.async_forward_entry_setups", + return_value=True, + ), + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + with patch( + "homeassistant.config_entries.ConfigEntries.async_unload_platforms", + return_value=False, + ): + assert not await hass.config_entries.async_unload(mock_config_entry.entry_id) + + mock_receiver.disconnect.assert_not_awaited() diff --git a/tests/components/tonewinner/test_media_player_entity.py b/tests/components/tonewinner/test_media_player_entity.py new file mode 100644 index 00000000000000..e809161050089f --- /dev/null +++ b/tests/components/tonewinner/test_media_player_entity.py @@ -0,0 +1,489 @@ +"""Test the Tonewinner media player entity.""" + +from collections.abc import Callable +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.media_player import ( + ATTR_SOUND_MODE, + MediaPlayerEntityFeature, + MediaPlayerState, +) +from homeassistant.components.tonewinner.const import DOMAIN +from homeassistant.components.tonewinner.media_player import ( + INPUT_SOURCES, + SOUND_MODES, + TonewinnerMediaPlayer, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + +ENTITY_ID = "media_player.at_500" + + +async def _setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Set up the integration against a mocked receiver.""" + with patch( + "homeassistant.components.tonewinner.TonewinnerReceiver", + return_value=mock_receiver, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + +def _state_callback(mock_receiver: MagicMock) -> Callable[[object], None]: + """Return the callback the entity registered for receiver state updates.""" + return mock_receiver.subscribe.call_args.args[0] + + +async def _call_media_player_service( + hass: HomeAssistant, service: str, **service_data: object +) -> None: + """Call a media player service on the test entity.""" + await hass.services.async_call( + "media_player", + service, + {"entity_id": ENTITY_ID, **service_data}, + blocking=True, + ) + + +async def test_media_player_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test media player entity setup.""" + mock_config_entry.add_to_hass(hass) + await _setup_integration(hass, mock_config_entry, mock_receiver) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == MediaPlayerState.OFF + assert state.attributes["friendly_name"] == "AT-500" + + entry = entity_registry.async_get(ENTITY_ID) + assert entry is not None + assert entry.unique_id == mock_config_entry.entry_id + + +async def test_media_player_device_info( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test media player device info comes from stored data without querying.""" + mock_config_entry.add_to_hass(hass) + await _setup_integration(hass, mock_config_entry, mock_receiver) + + device = device_registry.async_get_device_by_identifier( + (DOMAIN, mock_config_entry.entry_id), mock_config_entry.entry_id + ) + assert device is not None + assert device.manufacturer == "Tonewinner" + assert device.model == "AT-500" + mock_receiver.query_info.assert_not_called() + + +async def test_media_player_supported_features( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test media player supported features.""" + mock_config_entry.add_to_hass(hass) + await _setup_integration(hass, mock_config_entry, mock_receiver) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes["supported_features"] == ( + MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.SELECT_SOUND_MODE + ) + + +async def test_media_player_source_and_sound_mode_lists( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test the source list and sound mode list are derived from the library.""" + mock_config_entry.add_to_hass(hass) + await _setup_integration(hass, mock_config_entry, mock_receiver) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes["source_list"] == list(INPUT_SOURCES) + assert state.attributes["sound_mode_list"] == list(SOUND_MODES) + + +@pytest.mark.parametrize( + ("volume", "expected"), + [ + (0.5, 40.0), + (1.0, 80.0), + (0.0, 0.0), + (0.33, 26.5), + ], + ids=["half", "max", "zero", "off_grid_snapped"], +) +async def test_media_player_set_volume_level( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, + volume: float, + expected: float, +) -> None: + """Test setting volume level maps onto the receiver's half-step grid.""" + mock_config_entry.add_to_hass(hass) + await _setup_integration(hass, mock_config_entry, mock_receiver) + + await _call_media_player_service(hass, "volume_set", volume_level=volume) + + mock_receiver.set_volume.assert_called_once_with(expected) + + +@pytest.mark.parametrize( + ("service", "method"), + [ + ("volume_up", "volume_up"), + ("volume_down", "volume_down"), + ], + ids=["up", "down"], +) +async def test_media_player_volume_step( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, + service: str, + method: str, +) -> None: + """Test volume stepping delegates to the receiver.""" + mock_config_entry.add_to_hass(hass) + await _setup_integration(hass, mock_config_entry, mock_receiver) + + await _call_media_player_service(hass, service) + + getattr(mock_receiver, method).assert_called_once() + + +@pytest.mark.parametrize( + ("mute", "method"), + [(True, "mute_on"), (False, "mute_off")], + ids=["on", "off"], +) +async def test_media_player_mute_volume( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, + mute: bool, + method: str, +) -> None: + """Test muting and unmuting the media player.""" + mock_config_entry.add_to_hass(hass) + await _setup_integration(hass, mock_config_entry, mock_receiver) + + await _call_media_player_service(hass, "volume_mute", is_volume_muted=mute) + + getattr(mock_receiver, method).assert_called_once() + + +async def test_media_player_turn_on( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test turning on the media player.""" + mock_config_entry.add_to_hass(hass) + await _setup_integration(hass, mock_config_entry, mock_receiver) + + await _call_media_player_service(hass, "turn_on") + + mock_receiver.power_on.assert_called_once() + + +async def test_media_player_turn_off_clears_source( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test turning off the media player clears source state.""" + mock_config_entry.add_to_hass(hass) + await _setup_integration(hass, mock_config_entry, mock_receiver) + + mock_receiver.state.power = True + mock_receiver.state.source_name = "HDMI 1" + _state_callback(mock_receiver)(mock_receiver.state) + await hass.async_block_till_done() + + await _call_media_player_service(hass, "turn_off") + + mock_receiver.power_off.assert_called_once() + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == MediaPlayerState.OFF + assert state.attributes.get("source") is None + + +async def test_media_player_select_source( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test selecting input source.""" + mock_config_entry.add_to_hass(hass) + await _setup_integration(hass, mock_config_entry, mock_receiver) + + await _call_media_player_service(hass, "select_source", source="HDMI 1") + + mock_receiver.select_source.assert_called_once_with("HD1") + + +async def test_media_player_select_sound_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test selecting sound mode.""" + mock_config_entry.add_to_hass(hass) + await _setup_integration(hass, mock_config_entry, mock_receiver) + + await _call_media_player_service(hass, "select_sound_mode", sound_mode="Stereo") + + mock_receiver.select_sound_mode.assert_called_once_with("STEREO") + + +@pytest.mark.parametrize( + ("service", "payload"), + [ + ("select_source", {"source": "Nope"}), + ("select_source", {"source": "HD1"}), + ("select_sound_mode", {"sound_mode": "Nope"}), + ], + ids=["unknown_source", "raw_source_code", "unknown_sound_mode"], +) +async def test_media_player_invalid_selections( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, + service: str, + payload: dict[str, str], +) -> None: + """Test invalid source and sound mode selections raise an error.""" + mock_config_entry.add_to_hass(hass) + await _setup_integration(hass, mock_config_entry, mock_receiver) + + with pytest.raises(HomeAssistantError): + await _call_media_player_service(hass, service, **payload) + + mock_receiver.select_source.assert_not_called() + mock_receiver.select_sound_mode.assert_not_called() + + +@pytest.mark.parametrize( + ("state_updates", "attribute", "expected"), + [ + ( + {"power": True, "volume": 40.0, "source_name": "HDMI 1"}, + "volume_level", + 0.5, + ), + ( + {"power": True, "mute": True, "source_name": "HDMI 1"}, + "is_volume_muted", + True, + ), + ({"power": True, "source_name": "HDMI 1"}, "source", "HDMI 1"), + ( + {"power": True, "sound_mode_label": "Direct", "source_name": "HDMI 1"}, + ATTR_SOUND_MODE, + "Direct", + ), + ], + ids=["volume", "mute", "source", "sound_mode"], +) +async def test_media_player_state_updates( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, + state_updates: dict[str, object], + attribute: str, + expected: object, +) -> None: + """Test receiver state updates are reflected on the entity.""" + mock_config_entry.add_to_hass(hass) + await _setup_integration(hass, mock_config_entry, mock_receiver) + + for key, value in state_updates.items(): + setattr(mock_receiver.state, key, value) + _state_callback(mock_receiver)(mock_receiver.state) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.attributes[attribute] == expected + mock_receiver.query_source.assert_not_called() + + +async def test_media_player_power_off_from_callback_clears_source( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test the source clears on power-off and resumes from cache on power-on.""" + mock_config_entry.add_to_hass(hass) + await _setup_integration(hass, mock_config_entry, mock_receiver) + + mock_receiver.state.power = True + mock_receiver.state.source_name = "HDMI 1" + _state_callback(mock_receiver)(mock_receiver.state) + await hass.async_block_till_done() + + mock_receiver.state.power = False + _state_callback(mock_receiver)(mock_receiver.state) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == MediaPlayerState.OFF + assert state.attributes.get("source") is None + + # Receivers resume their previous input on power-on, so the retained + # source becomes visible again once a power-on report arrives. + mock_receiver.state.power = True + _state_callback(mock_receiver)(mock_receiver.state) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == MediaPlayerState.ON + assert state.attributes["source"] == "HDMI 1" + + +async def test_media_player_unavailable_on_disconnect_and_recovery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test loss triggers one reload attempt and later state restores the entity.""" + mock_config_entry.add_to_hass(hass) + await _setup_integration(hass, mock_config_entry, mock_receiver) + + with patch.object( + hass.config_entries, "async_schedule_reload" + ) as mock_schedule_reload: + _state_callback(mock_receiver)(None) + await hass.async_block_till_done() + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == "unavailable" + assert "Connection to the Tonewinner receiver was lost" in caplog.text + mock_schedule_reload.assert_called_once_with(mock_config_entry.entry_id) + + # A repeated disconnect notification must not schedule another reload. + _state_callback(mock_receiver)(None) + await hass.async_block_till_done() + mock_schedule_reload.assert_called_once_with(mock_config_entry.entry_id) + + _state_callback(mock_receiver)(mock_receiver.state) + await hass.async_block_till_done() + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == MediaPlayerState.OFF + assert "Connection to the Tonewinner receiver was restored" in caplog.text + + +@pytest.mark.parametrize( + ("service", "method", "payload"), + [ + ("turn_on", "power_on", {}), + ("turn_off", "power_off", {}), + ("volume_set", "set_volume", {"volume_level": 0.5}), + ("volume_up", "volume_up", {}), + ("volume_down", "volume_down", {}), + ("volume_mute", "mute_on", {"is_volume_muted": True}), + ("select_source", "select_source", {"source": "HDMI 1"}), + ("select_sound_mode", "select_sound_mode", {"sound_mode": "Stereo"}), + ], + ids=[ + "on", + "off", + "volume", + "up", + "down", + "mute", + "source", + "sound_mode", + ], +) +async def test_media_player_command_failure_raises_ha_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, + service: str, + method: str, + payload: dict[str, float | bool | str], +) -> None: + """Test receiver I/O failures surface as Home Assistant errors.""" + mock_config_entry.add_to_hass(hass) + await _setup_integration(hass, mock_config_entry, mock_receiver) + + getattr(mock_receiver, method).side_effect = ConnectionError("link down") + + with pytest.raises(HomeAssistantError, match="link down"): + await _call_media_player_service(hass, service, **payload) + + +def test_sound_mode_codes_prefer_canonical() -> None: + """Test duplicate labels keep the canonical code over firmware misspellings.""" + assert SOUND_MODES["Direct"] == "DIRECT" + assert SOUND_MODES["All Stereo"] == "ALLSTEREO" + + +@pytest.mark.parametrize( + ("source_name", "audio_source", "expected"), + [ + ("HDMI 1", None, "HDMI 1"), + ("hdmi 1", None, "HDMI 1"), + ("HD1", None, "HDMI 1"), + ("eARC/ARC", None, "HDMI eARC"), + ("Mystery Input", "HD1", "HDMI 1"), + ("Mystery Input", None, "Mystery Input"), + ], + ids=[ + "exact_name", + "case_insensitive_name", + "code", + "firmware_earc_label", + "audio_source_fallback", + "passthrough", + ], +) +def test_media_player_resolve_source( + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, + source_name: str, + audio_source: str | None, + expected: str | None, +) -> None: + """Test resolving device-reported source names to display names.""" + mock_config_entry.runtime_data = mock_receiver + + entity = TonewinnerMediaPlayer(mock_config_entry) + + assert entity._resolve_source(source_name, audio_source) == expected diff --git a/tests/components/unifiprotect/fixtures/sample_nvr.json b/tests/components/unifiprotect/fixtures/sample_nvr.json index 60984bab04884b..2a5a54c2a93098 100644 --- a/tests/components/unifiprotect/fixtures/sample_nvr.json +++ b/tests/components/unifiprotect/fixtures/sample_nvr.json @@ -5,7 +5,7 @@ "canAutoUpdate": true, "isStatsGatheringEnabled": true, "timezone": "America/New_York", - "version": "7.1.0", + "version": "7.2.105", "ucoreVersion": "2.3.26", "firmwareVersion": "2.3.10", "uiVersion": null, diff --git a/tests/components/unifiprotect/snapshots/test_diagnostics.ambr b/tests/components/unifiprotect/snapshots/test_diagnostics.ambr index f170eba679b8dd..8447f02c2318ec 100644 --- a/tests/components/unifiprotect/snapshots/test_diagnostics.ambr +++ b/tests/components/unifiprotect/snapshots/test_diagnostics.ambr @@ -603,7 +603,7 @@ 'uptime': 1191516000, 'vaultCameras': list([ ]), - 'version': '7.1.0', + 'version': '7.2.105', 'wanIp': None, }), 'ringtones': list([ diff --git a/tests/components/unifiprotect/snapshots/test_init.ambr b/tests/components/unifiprotect/snapshots/test_init.ambr index 6f4878ad305c4b..d4c6339624941b 100644 --- a/tests/components/unifiprotect/snapshots/test_init.ambr +++ b/tests/components/unifiprotect/snapshots/test_init.ambr @@ -29,7 +29,7 @@ 'name': 'UnifiProtect', 'name_by_user': None, 'serial_number': None, - 'sw_version': '7.1.0', + 'sw_version': '7.2.105', 'via_device_id': None, }) # --- diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index e502afca01a4c7..0fe203fcf299b9 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -446,6 +446,7 @@ async def _prime_public_only() -> Any: assert device.via_device_id is None assert device.name == camera.display_name assert device.model == camera.type + assert device.model_id == camera.type assert ( await async_get_stream_source(hass, entity_id) diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index df2f916b3901bd..19e9a2464c15b7 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -1626,7 +1626,7 @@ async def test_reconfigure_outdated_version( # Set up NVR with outdated version old_nvr = nvr.model_copy() - old_nvr.version = Version("5.0.0") # Below MIN_REQUIRED_PROTECT_V (7.1.0) + old_nvr.version = Version("5.0.0") # Below MIN_REQUIRED_PROTECT_V (7.2.105) bootstrap.nvr = old_nvr result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/unifiprotect/test_light.py b/tests/components/unifiprotect/test_light.py index 4316feb85cb620..81a445c993b8e0 100644 --- a/tests/components/unifiprotect/test_light.py +++ b/tests/components/unifiprotect/test_light.py @@ -8,7 +8,10 @@ from uiprotect.websocket import WebsocketState from homeassistant.components.light import ATTR_BRIGHTNESS -from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION +from homeassistant.components.unifiprotect.const import ( + DEFAULT_ATTRIBUTION, + DEFAULT_BRAND, +) from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_ENTITY_ID, @@ -18,7 +21,7 @@ Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from .utils import ( MockUFPFixture, @@ -212,6 +215,7 @@ async def test_light_turn_off( async def test_light_setup_public_only( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ufp: MockUFPFixture, light: Light, @@ -233,6 +237,13 @@ async def test_light_setup_public_only( assert state assert state.state == STATE_OFF + # Device identity comes from the public object alone. + device = device_registry.async_get(entity.device_id) + assert device + assert device.model == public.type + assert device.model_id == public.type + assert device.manufacturer == DEFAULT_BRAND + async def test_light_added_after_setup_public_only( hass: HomeAssistant, diff --git a/tests/components/vacuum/test_llm.py b/tests/components/vacuum/test_llm.py index 441b1d8eb6df26..e24bf00f3c94eb 100644 --- a/tests/components/vacuum/test_llm.py +++ b/tests/components/vacuum/test_llm.py @@ -10,7 +10,11 @@ from homeassistant.setup import async_setup_component ENTITY_ID = "vacuum.test" -INTENTS = {"HassVacuumCleanArea", "HassVacuumReturnToBase", "HassVacuumStart"} +TOOL_NAMES = { + "vacuum__HassVacuumCleanArea", + "vacuum__HassVacuumReturnToBase", + "vacuum__HassVacuumStart", +} @pytest.fixture(autouse=True) @@ -44,13 +48,13 @@ async def _tool_names(hass: HomeAssistant) -> set[str]: async def test_intent_tool_exposed(hass: HomeAssistant) -> None: """Test the intent tool is offered for an exposed vacuum entity.""" - assert await _tool_names(hass) >= INTENTS + assert await _tool_names(hass) >= TOOL_NAMES async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: """Test the intent tool is hidden when no vacuum entity is exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) - assert not INTENTS & await _tool_names(hass) + assert not TOOL_NAMES & await _tool_names(hass) assert vacuum_llm.async_get_tools(hass, _llm_context(), "assist") is None diff --git a/tests/components/vesync/test_coordinator.py b/tests/components/vesync/test_coordinator.py new file mode 100644 index 00000000000000..fb70ee71c1fcb2 --- /dev/null +++ b/tests/components/vesync/test_coordinator.py @@ -0,0 +1,34 @@ +"""Tests for the VeSync coordinator.""" + +from datetime import timedelta +import time + +from freezegun.api import FrozenDateTimeFactory +from pyvesync import VeSync + +from homeassistant.components.vesync.const import UPDATE_INTERVAL_ENERGY +from homeassistant.components.vesync.coordinator import VeSyncDataCoordinator +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + + +async def test_should_update_energy( + hass: HomeAssistant, + config_entry: ConfigEntry, + manager: VeSync, + freezer: FrozenDateTimeFactory, +) -> None: + """Test energy data is only refreshed once per interval.""" + coordinator = VeSyncDataCoordinator(hass, config_entry, manager) + + # Nothing fetched yet + assert coordinator.should_update_energy() + + coordinator.update_time = time.time() + assert not coordinator.should_update_energy() + + freezer.tick(timedelta(seconds=UPDATE_INTERVAL_ENERGY - 1)) + assert not coordinator.should_update_energy() + + freezer.tick(timedelta(seconds=1)) + assert coordinator.should_update_energy() diff --git a/tests/components/wirelesstag/test_binary_sensor.py b/tests/components/wirelesstag/test_binary_sensor.py new file mode 100644 index 00000000000000..4932df073e273c --- /dev/null +++ b/tests/components/wirelesstag/test_binary_sensor.py @@ -0,0 +1,89 @@ +"""Tests for the Wireless Sensor Tags binary sensor platform.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.wirelesstag.const import SIGNAL_BINARY_EVENT_UPDATE +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.setup import async_setup_component + +CONFIG = { + "wirelesstag": {"username": "foo@bar.com", "password": "secret"}, + "binary_sensor": { + "platform": "wirelesstag", + "monitored_conditions": ["motion", "dry", "wet"], + }, +} + +UUID = "00000000-0000-0000-0000-000000000001" +TAG_ID = 1 +MAC = "ABCDEF012345" + +EVENT_NAMES = {"motion": "Motion", "dry": "Too dry", "wet": "Too wet"} + + +def _mock_tag() -> MagicMock: + """Return a mocked wirelesstagpy SensorTag with motion/dry/wet events off.""" + tag = MagicMock() + tag.uuid = UUID + tag.tag_id = TAG_ID + tag.tag_manager_mac = MAC + tag.name = "Bedroom" + tag.is_alive = True + tag.supported_binary_events_types = list(EVENT_NAMES) + tag.battery_remaining = 0.85 + tag.battery_volts = 3.0 + tag.signal_strength = -60 + tag.is_in_range = True + tag.power_consumption = 1.5 + + events = {} + for event_type, human_readable_name in EVENT_NAMES.items(): + event = MagicMock() + event.human_readable_name = human_readable_name + event.is_state_on = False + events[event_type] = event + tag.event.__getitem__.side_effect = events.__getitem__ + return tag + + +@pytest.mark.parametrize("event_type", ["motion", "dry", "wet"]) +async def test_binary_sensor_receives_push_update( + hass: HomeAssistant, + event_type: str, + entity_registry: er.EntityRegistry, +) -> None: + """Test binary sensors update from push events for every event type. + + The push side dispatches the signal keyed by the library event type, so the + entity must subscribe with the same key. Events whose device_class is None + (dry/wet) previously subscribed with "None" and never updated. + """ + tag = _mock_tag() + with patch("homeassistant.components.wirelesstag.WirelessTags") as mock_api_class: + mock_api = mock_api_class.return_value + mock_api.load_tags.return_value = {tag.uuid: tag} + + assert await async_setup_component(hass, "wirelesstag", CONFIG) + await hass.async_block_till_done() + assert await async_setup_component(hass, "binary_sensor", CONFIG) + await hass.async_block_till_done() + + entity_id = entity_registry.async_get_entity_id( + "binary_sensor", "wirelesstag", f"{UUID}_{event_type}" + ) + assert entity_id is not None + assert hass.states.get(entity_id).state == STATE_OFF + + # Simulate a push notification turning the event on. + tag.event[event_type].is_state_on = True + async_dispatcher_send( + hass, SIGNAL_BINARY_EVENT_UPDATE.format(TAG_ID, event_type, MAC), tag + ) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_ON diff --git a/tests/conftest.py b/tests/conftest.py index 082f1278ddd14c..cb2417a98d632c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -202,6 +202,9 @@ def pytest_runtest_setup() -> None: - Modified to include https://github.com/spulec/freezegun/pull/424 and improve class str. + + - Modified to not force lazily imported module attributes to be resolved + when scanning the loaded modules for time related objects. """ pytest_socket.socket_allow_hosts(["127.0.0.1"]) pytest_socket.disable_socket(allow_unix_socket=True) @@ -236,6 +239,10 @@ def gethostbyname_ex_patched(host, *args, **kwargs): freezegun.api.datetime_to_fakedatetime = patch_time.ha_datetime_to_fakedatetime # type: ignore[attr-defined] freezegun.api.FakeDatetime = patch_time.HAFakeDatetime # type: ignore[attr-defined] + freezegun.api._get_module_attributes = patch_time.ha_get_module_attributes # type: ignore[attr-defined] + freezegun.api._get_module_attributes_hash = ( # type: ignore[attr-defined] + patch_time.ha_get_module_attributes_hash + ) def adapt_datetime(val): return val.isoformat(" ") diff --git a/tests/helpers/test_json.py b/tests/helpers/test_json.py index 48b6519870307b..9807e2a1552609 100644 --- a/tests/helpers/test_json.py +++ b/tests/helpers/test_json.py @@ -54,6 +54,17 @@ def test_json_encoder(hass: HomeAssistant, encoder: type[json.JSONEncoder]) -> N assert json_round_trip(default) == json_round_trip(state.as_dict()) +def test_default_json_encoder(hass: HomeAssistant) -> None: + """Test the default JSON encoder for date and time.""" + ha_json_enc = DefaultHASSJSONEncoder() + + today = datetime.date(2026, 8, 23) + assert ha_json_enc.default(today) == today.isoformat() + + current_time = datetime.time(12, 0) + assert ha_json_enc.default(current_time) == current_time.isoformat() + + def test_json_encoder_raises(hass: HomeAssistant) -> None: """Test the JSON encoder raises on unsupported types.""" ha_json_enc = DefaultHASSJSONEncoder() @@ -147,6 +158,27 @@ def test_json_dumps_rgb_color_subclass() -> None: assert json_dumps(rgb) == "[4,2,1]" +def test_json_dumps_date_time_subclasses() -> None: + """Test the json dumps with date and time subclasses.""" + + class CustomDate(datetime.date): + """Custom date subclass.""" + + class CustomTime(datetime.time): + """Custom time subclass.""" + + class CustomDatetime(datetime.datetime): + """Custom datetime subclass.""" + + d = CustomDate(2026, 8, 23) + t = CustomTime(12, 30, 45) + dt = CustomDatetime(2026, 8, 23, 12, 30, 45) + + assert json_dumps({"date": d, "time": t, "datetime": dt}) == ( + '{"date":"2026-08-23","time":"12:30:45","datetime":"2026-08-23T12:30:45"}' + ) + + def test_json_fragments() -> None: """Test the json dumps with a fragment.""" diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 873d2342bb6022..8f6d59b0ca137d 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -316,14 +316,14 @@ async def test_assist_api_get_timer_tools( assert await async_setup_component(hass, "intent", {}) api = await llm.async_get_api(hass, "assist", llm_context) - assert "HassStartTimer" not in [tool.name for tool in api.tools] + assert "intent__HassStartTimer" not in [tool.name for tool in api.tools] llm_context.device_id = "test_device" async_register_timer_handler(hass, "test_device", lambda *args: None) api = await llm.async_get_api(hass, "assist", llm_context) - assert "HassStartTimer" in [tool.name for tool in api.tools] + assert "intent__HassStartTimer" in [tool.name for tool in api.tools] async def test_assist_api_description( @@ -670,7 +670,7 @@ def create_entity( """ first_part_prompt = ( "When controlling Home Assistant always call the intent tools. " - "Use HassTurnOn to lock and HassTurnOff to unlock a lock. " + "Use intent__HassTurnOn to lock and intent__HassTurnOff to unlock a lock. " "When controlling a device, prefer passing just name and domain. " "When controlling an area, prefer passing just area name and domain." ) @@ -683,7 +683,7 @@ def create_entity( dynamic_context_prompt = ( "You ARE equipped to answer questions about the" " current state of\nthe home using the" - " `GetLiveContext` tool. This is a primary" + " `homeassistant__GetLiveContext` tool. This is a primary" " function. Do not state you lack the\n" "functionality if the question requires live" " data.\nIf the user asks about device" @@ -695,7 +695,7 @@ def create_entity( ' "What mode is the thermostat in?", "What' ' is the temperature outside?"):\n' " 1. Recognize this requires live data.\n" - " 2. You MUST call `GetLiveContext`. This" + " 2. You MUST call `homeassistant__GetLiveContext`. This" " tool will provide the needed real-time" " information (like temperature from the local" " weather, lock status, etc.).\n" @@ -715,10 +715,10 @@ def create_entity( {no_timer_prompt}""" ) - # Verify that the GetLiveContext tool returns the same results + # Verify that the homeassistant__GetLiveContext tool returns the same results # as the exposed_entities_prompt result = await api.async_call_tool( - llm.ToolInput(tool_name="GetLiveContext", tool_args={}) + llm.ToolInput(tool_name="homeassistant__GetLiveContext", tool_args={}) ) assert result == { "success": True, @@ -845,7 +845,7 @@ async def test_action_tool( assert len(tools) == 2 tool = tools[0] - assert tool.name == "test_script" + assert tool.name == "script__test_script" assert ( tool.description == "This is a test script. Aliases: ['script alias', 'script name']" @@ -869,7 +869,7 @@ async def test_action_tool( # Test script with response tool_input = llm.ToolInput( - tool_name="test_script", + tool_name="script__test_script", tool_args={ "beer": "3", "wine": 0, @@ -908,7 +908,7 @@ async def test_action_tool( # Test script with no response tool_input = llm.ToolInput( - tool_name="script_with_no_fields", + tool_name="script__script_with_no_fields", tool_args={}, ) @@ -964,7 +964,7 @@ async def test_action_tool( assert len(tools) == 2 tool = tools[0] - assert tool.name == "test_script" + assert tool.name == "script__test_script" assert ( tool.description == "This is a new test script. Aliases: ['script alias', 'script name']" @@ -1269,8 +1269,11 @@ async def test_no_tools_exposed(hass: HomeAssistant) -> None: device_id=None, ) api = await llm.async_get_api(hass, "assist", llm_context) - # GetLiveContext is always offered; it reports when nothing is exposed. - assert [tool.name for tool in api.tools] == ["GetLiveContext", "GetDateTime"] + # homeassistant__GetLiveContext is always offered; it reports when nothing is exposed. + assert [tool.name for tool in api.tools] == [ + "homeassistant__GetLiveContext", + "llm__GetDateTime", + ] async def test_merged_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: diff --git a/tests/mypy_plugins/test_enum_identity_compare.py b/tests/mypy_plugins/test_enum_identity_compare.py index 5707124aa02209..1f7b34152323c0 100644 --- a/tests/mypy_plugins/test_enum_identity_compare.py +++ b/tests/mypy_plugins/test_enum_identity_compare.py @@ -1,6 +1,6 @@ """Tests for the enum_identity_compare mypy plugin. -Each test snippet is run through mypy's API with the plugin enabled. +Each test snippet is run through mypy in a subprocess with the plugin enabled. Tests assert the number of ``home-assistant-enum-identity-compare`` errors emitted and the relevant message content (operator pair and enum class name). @@ -12,10 +12,10 @@ import os from pathlib import Path +import subprocess import sys import textwrap -from mypy import api as mypy_api import pytest _IS_EQ = ("`is`", "`==`") @@ -46,24 +46,29 @@ def _run_mypy(code: str, tmp_path: Path, mypy_path: str | None = None) -> list[s config_body += f"mypy_path = {mypy_path}\n" config.write_text(config_body) - env_pythonpath = os.environ.get("PYTHONPATH", "") - os.environ["PYTHONPATH"] = f"{_PLUGINS_ROOT}{os.pathsep}{env_pythonpath}" - # Make sure mypy can import the plugin from the current process. - sys.path.insert(0, str(_PLUGINS_ROOT)) - try: - # mypy ships as a compiled extension; pylint can't introspect it. - stdout, _stderr, _rc = mypy_api.run( # pylint: disable=c-extension-no-member - [ - "--no-incremental", - f"--cache-dir={cache}", - "--config-file", - str(config), - str(src), - ] - ) - finally: - os.environ["PYTHONPATH"] = env_pythonpath - sys.path.pop(0) + # Run mypy in a subprocess: type checking a snippet in process leaves + # hundreds of thousands of objects behind, which the next test module pays + # for in its garbage collection. + env = os.environ.copy() + env["PYTHONPATH"] = os.pathsep.join( + filter(None, (str(_PLUGINS_ROOT), env.get("PYTHONPATH"))) + ) + stdout = subprocess.run( + [ + sys.executable, + "-m", + "mypy", + "--no-incremental", + f"--cache-dir={cache}", + "--config-file", + str(config), + str(src), + ], + capture_output=True, + check=False, + encoding="utf-8", + env=env, + ).stdout errors: list[str] = [] for line in stdout.splitlines(): diff --git a/tests/patch_time.py b/tests/patch_time.py index 82d776b6e768d4..706ce622c6670e 100644 --- a/tests/patch_time.py +++ b/tests/patch_time.py @@ -2,6 +2,8 @@ import datetime import time +import types +from typing import Any import freezegun @@ -24,6 +26,27 @@ def ha_datetime_to_fakedatetime(datetime) -> freezegun.api.FakeDatetime: # type ) +def ha_get_module_attributes(module: types.ModuleType) -> list[tuple[str, Any]]: + """Return the attributes of a module. + + Modified to only look at attributes which are already set, instead of every + name dir() offers. Packages with a lazy __getattr__, such as scipy or + elevenlabs, import their whole tree when each name is read, which takes + seconds and is charged to whichever test freezes time next. + """ + return list(getattr(module, "__dict__", {}).items()) + + +def ha_get_module_attributes_hash(module: types.ModuleType) -> str: + """Return a hash of the module attributes. + + Modified to hash the same namespace ha_get_module_attributes reads, so that + a module which grows an attribute after it was first scanned is scanned + again. dir() does not report such a change for lazy modules. + """ + return f"{id(module)}-{hash(frozenset(getattr(module, '__dict__', {})))}" + + class HAFakeDateMeta(freezegun.api.FakeDateMeta): """Modified to override the string representation.""" diff --git a/tests/test_patch_time.py b/tests/test_patch_time.py new file mode 100644 index 00000000000000..a5b6cb0dfeb979 --- /dev/null +++ b/tests/test_patch_time.py @@ -0,0 +1,55 @@ +"""Test the freezegun modifications in tests.patch_time.""" + +from collections.abc import Generator +import datetime +import sys +import types + +from freezegun import freeze_time +import pytest + + +@pytest.fixture +def lazy_module() -> Generator[types.ModuleType]: + """Register a module which only resolves its attributes when they are read.""" + module = types.ModuleType("freezegun_lazy_module") + module.probed = [] + + def module_getattr(name: str) -> object: + module.probed.append(name) + raise AttributeError(name) + + module.__getattr__ = module_getattr + module.__dir__ = lambda: ["lazy_attribute"] + + sys.modules[module.__name__] = module + yield module + del sys.modules[module.__name__] + + +def test_freezing_time_does_not_resolve_lazy_attributes( + lazy_module: types.ModuleType, +) -> None: + """Test freezing time does not read attributes which are not set yet.""" + with freeze_time("2023-01-01"): + pass + + assert lazy_module.probed == [] + + +def test_freezing_time_rescans_a_changed_module( + lazy_module: types.ModuleType, +) -> None: + """Test an attribute set after the first freeze is patched by the next one.""" + real_datetime = datetime.datetime + + with freeze_time("2023-01-01"): + pass + + lazy_module.datetime = real_datetime + + with freeze_time("2023-01-01"): + assert lazy_module.datetime is not real_datetime + assert lazy_module.datetime.now() == real_datetime(2023, 1, 1) + + assert lazy_module.datetime is real_datetime