From 4550565c7a42933c6f215179b0beb97f1ac60ef7 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sun, 20 Sep 2026 18:28:40 +0300 Subject: [PATCH 1/8] Preserve native code interpreter items in openai_conversation (#182715) --- .../components/openai_conversation/entity.py | 36 +++++- .../openai_conversation/__init__.py | 41 ++++--- .../snapshots/test_conversation.ambr | 110 ++++++++++++++++-- .../snapshots/test_entity.ambr | 74 ++++++++++++ .../openai_conversation/test_conversation.py | 29 ++++- .../openai_conversation/test_entity.py | 65 ++++++++++- 6 files changed, 324 insertions(+), 31 deletions(-) create mode 100644 tests/components/openai_conversation/snapshots/test_entity.ambr diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index ff0affcaa55bf7..dfe6cf4e7fc432 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -14,6 +14,7 @@ EasyInputMessageParam, FunctionToolParam, ResponseCodeInterpreterToolCall, + ResponseCodeInterpreterToolCallParam, ResponseCompletedEvent, ResponseErrorEvent, ResponseFailedEvent, @@ -41,6 +42,9 @@ ToolParam, WebSearchToolParam, ) +from openai.types.responses.response_code_interpreter_tool_call_param import ( + Output as CodeInterpreterOutputParam, +) from openai.types.responses.response_create_params import ( Reasoning, ResponseCreateParamsStreaming, @@ -160,6 +164,7 @@ def _convert_content_to_param( messages: ResponseInputParam = [] reasoning_summary: list[str] = [] web_search_calls: dict[str, ResponseFunctionWebSearchParam] = {} + code_interpreter_calls: dict[str, llm.ToolInput] = {} for content in chat_content: if isinstance(content, conversation.ToolResultContent): @@ -172,6 +177,24 @@ def _convert_content_to_param( "status", "completed" ) messages.append(web_search_call) + elif ( + content.tool_name == "code_interpreter" + and content.tool_call_id in code_interpreter_calls + ): + tool_call = code_interpreter_calls.pop(content.tool_call_id) + messages.append( + ResponseCodeInterpreterToolCallParam( + type="code_interpreter_call", + id=tool_call.id, + code=tool_call.tool_args["code"], + container_id=cast(str, content.result.data["container_id"]), + outputs=cast( + list[CodeInterpreterOutputParam] | None, + content.result.data["output"], + ), + status=content.result.data["status"], # type: ignore[typeddict-item] + ) + ) else: messages.append( FunctionCallOutput( @@ -211,6 +234,10 @@ def _convert_content_to_param( action=tool_call.tool_args["action"], status="completed", ) + elif ( + tool_call.external and tool_call.tool_name == "code_interpreter" + ): + code_interpreter_calls[tool_call.id] = tool_call else: messages.append( ResponseFunctionToolCallParam( @@ -311,10 +338,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have llm.ToolInput( id=event.item.id, tool_name="code_interpreter", - tool_args={ - "code": event.item.code, - "container": event.item.container_id, - }, + tool_args={"code": event.item.code}, external=True, ) ] @@ -325,11 +349,13 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have "tool_name": "code_interpreter", "result": llm.ToolResult( data={ + "container_id": event.item.container_id, "output": ( [output.to_dict() for output in event.item.outputs] # type: ignore[misc] if event.item.outputs is not None else None - ) + ), + "status": event.item.status, }, error=event.item.status == "failed", ), diff --git a/tests/components/openai_conversation/__init__.py b/tests/components/openai_conversation/__init__.py index ccd41dffbdadf5..32d2d59463389c 100644 --- a/tests/components/openai_conversation/__init__.py +++ b/tests/components/openai_conversation/__init__.py @@ -1,5 +1,7 @@ """Tests for the OpenAI Conversation integration.""" +from typing import Literal + from openai.types.responses import ( ResponseCodeInterpreterCallCodeDeltaEvent, ResponseCodeInterpreterCallCodeDoneEvent, @@ -325,7 +327,11 @@ def create_web_search_item(id: str, output_index: int) -> list[ResponseStreamEve def create_code_interpreter_item( - id: str, code: str | list[str], output_index: int, logs: str | None = None + id: str, + code: str | list[str], + output_index: int, + logs: str | None = None, + status: Literal["completed", "incomplete", "failed"] = "completed", ) -> list[ResponseStreamEvent]: """Create a message item.""" if isinstance(code, str): @@ -382,26 +388,31 @@ def create_code_interpreter_item( sequence_number=0, type="response.code_interpreter_call.interpreting", ), + ] + ) + if status == "completed": + events.append( ResponseCodeInterpreterCallCompletedEvent( item_id=id, output_index=output_index, sequence_number=0, type="response.code_interpreter_call.completed", + ) + ) + events.append( + ResponseOutputItemDoneEvent( + item=ResponseCodeInterpreterToolCall( + id=id, + code=code, + container_id=container_id, + outputs=[OutputLogs(type="logs", logs=logs)] if logs else None, + status=status, + type="code_interpreter_call", ), - ResponseOutputItemDoneEvent( - item=ResponseCodeInterpreterToolCall( - id=id, - code=code, - container_id=container_id, - outputs=[OutputLogs(type="logs", logs=logs)] if logs else None, - status="completed", - type="code_interpreter_call", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.done", - ), - ] + output_index=output_index, + sequence_number=0, + type="response.output_item.done", + ) ) return events diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index 8d152c2aec524a..4d6708cbcdb664 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_code_interpreter +# name: test_code_interpreter[completed] list([ dict({ 'content': 'Please use the python tool to calculate square root of 55555', @@ -7,15 +7,109 @@ 'type': 'message', }), dict({ - 'arguments': '{"code":"import math\\nmath.sqrt(55555)","container":"cntr_A"}', - 'call_id': 'ci_A', - 'name': 'code_interpreter', - 'type': 'function_call', + 'code': ''' + import math + math.sqrt(55555) + ''', + 'container_id': 'cntr_A', + 'id': 'ci_A', + 'outputs': list([ + dict({ + 'logs': ''' + 235.70108188126758 + + ''', + 'type': 'logs', + }), + ]), + 'status': 'completed', + 'type': 'code_interpreter_call', }), dict({ - 'call_id': 'ci_A', - 'output': '{"data":{"output":[{"logs":"235.70108188126758\\n","type":"logs"}]},"error":false}', - 'type': 'function_call_output', + 'content': 'I’ve calculated it with Python: the square root of 55555 is approximately 235.70108188126758.', + 'role': 'assistant', + 'type': 'message', + }), + dict({ + 'content': 'Thank you!', + 'role': 'user', + 'type': 'message', + }), + dict({ + 'content': 'You are welcome!', + 'role': 'assistant', + 'type': 'message', + }), + ]) +# --- +# name: test_code_interpreter[failed] + list([ + dict({ + 'content': 'Please use the python tool to calculate square root of 55555', + 'role': 'user', + 'type': 'message', + }), + dict({ + 'code': ''' + import math + math.sqrt(55555) + ''', + 'container_id': 'cntr_A', + 'id': 'ci_A', + 'outputs': list([ + dict({ + 'logs': ''' + 235.70108188126758 + + ''', + 'type': 'logs', + }), + ]), + 'status': 'failed', + 'type': 'code_interpreter_call', + }), + dict({ + 'content': 'I’ve calculated it with Python: the square root of 55555 is approximately 235.70108188126758.', + 'role': 'assistant', + 'type': 'message', + }), + dict({ + 'content': 'Thank you!', + 'role': 'user', + 'type': 'message', + }), + dict({ + 'content': 'You are welcome!', + 'role': 'assistant', + 'type': 'message', + }), + ]) +# --- +# name: test_code_interpreter[incomplete] + list([ + dict({ + 'content': 'Please use the python tool to calculate square root of 55555', + 'role': 'user', + 'type': 'message', + }), + dict({ + 'code': ''' + import math + math.sqrt(55555) + ''', + 'container_id': 'cntr_A', + 'id': 'ci_A', + 'outputs': list([ + dict({ + 'logs': ''' + 235.70108188126758 + + ''', + 'type': 'logs', + }), + ]), + 'status': 'incomplete', + 'type': 'code_interpreter_call', }), dict({ 'content': 'I’ve calculated it with Python: the square root of 55555 is approximately 235.70108188126758.', diff --git a/tests/components/openai_conversation/snapshots/test_entity.ambr b/tests/components/openai_conversation/snapshots/test_entity.ambr new file mode 100644 index 00000000000000..388c230685c341 --- /dev/null +++ b/tests/components/openai_conversation/snapshots/test_entity.ambr @@ -0,0 +1,74 @@ +# serializer version: 1 +# name: test_convert_code_interpreter[custom_function] + list([ + dict({ + 'arguments': '{"code":"print(1)"}', + 'call_id': 'ci_A', + 'name': 'code_interpreter', + 'type': 'function_call', + }), + dict({ + 'call_id': 'ci_A', + 'output': '{"data":{"container_id":"cntr_A","output":null,"status":"completed"},"error":false}', + 'type': 'function_call_output', + }), + ]) +# --- +# name: test_convert_code_interpreter[failed] + list([ + dict({ + 'code': 'raise ValueError()', + 'container_id': 'cntr_A', + 'id': 'ci_A', + 'outputs': list([ + dict({ + 'logs': 'ValueError', + 'type': 'logs', + }), + ]), + 'status': 'failed', + 'type': 'code_interpreter_call', + }), + ]) +# --- +# name: test_convert_code_interpreter[image] + list([ + dict({ + 'code': 'plt.show()', + 'container_id': 'cntr_A', + 'id': 'ci_A', + 'outputs': list([ + dict({ + 'type': 'image', + 'url': 'https://example.com/plot.png', + }), + ]), + 'status': 'completed', + 'type': 'code_interpreter_call', + }), + ]) +# --- +# name: test_convert_code_interpreter[incomplete] + list([ + dict({ + 'code': 'print(1)', + 'container_id': 'cntr_A', + 'id': 'ci_A', + 'outputs': None, + 'status': 'incomplete', + 'type': 'code_interpreter_call', + }), + ]) +# --- +# name: test_convert_code_interpreter[no_output] + list([ + dict({ + 'code': None, + 'container_id': 'cntr_A', + 'id': 'ci_A', + 'outputs': None, + 'status': 'completed', + 'type': 'code_interpreter_call', + }), + ]) +# --- diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 515aab22c91e09..2817c269ffe5ed 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -1,6 +1,7 @@ """Tests for the OpenAI integration.""" import datetime +from typing import Literal from unittest.mock import AsyncMock from freezegun import freeze_time @@ -713,13 +714,22 @@ async def test_web_search_remove_citations_gpt5( assert result.response.speech["plain"]["speech"] == "The match ended 0-2." +@pytest.mark.usefixtures("mock_init_component") +@pytest.mark.parametrize( + "status", + [ + pytest.param("completed", id="completed"), + pytest.param("incomplete", id="incomplete"), + pytest.param("failed", id="failed"), + ], +) async def test_code_interpreter( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_init_component, - mock_create_stream, + mock_create_stream: AsyncMock, mock_chat_log: MockChatLog, # noqa: F811 snapshot: SnapshotAssertion, + status: Literal["completed", "incomplete", "failed"], ) -> None: """Test code_interpreter tool.""" subentry = next(iter(mock_config_entry.subentries.values())) @@ -744,6 +754,7 @@ async def test_code_interpreter( code=["import", " math", "\n", "math", ".sqrt", "(", "555", "55", ")"], logs="235.70108188126758\n", output_index=0, + status=status, ), *create_message_item(id="msg_A", text=message, output_index=1), ) @@ -763,6 +774,16 @@ async def test_code_interpreter( assert result.response.response_type is intent.IntentResponseType.ACTION_DONE assert result.response.speech["plain"]["speech"] == message, result.response.speech + assistant_content = mock_chat_log.content[2] + assert isinstance(assistant_content, conversation.AssistantContent) + assert assistant_content.tool_calls + assert assistant_content.tool_calls[0].tool_args == { + "code": "import math\nmath.sqrt(55555)" + } + tool_result = mock_chat_log.content[3] + assert isinstance(tool_result, conversation.ToolResultContent) + assert tool_result.result.data["container_id"] == "cntr_A" + # Test follow-up message in multi-turn conversation mock_create_stream.return_value = [ (*create_message_item(id="msg_B", text="You are welcome!", output_index=1),) @@ -777,6 +798,10 @@ async def test_code_interpreter( ) assert mock_create_stream.mock_calls[1][2]["input"][1:] == snapshot + assert mock_create_stream.mock_calls[1][2]["tools"] == [ + {"type": "code_interpreter", "container": {"type": "auto"}} + ] + assert mock_create_stream.mock_calls[1][2]["input"][2]["status"] == status async def test_flex_tier_retry( diff --git a/tests/components/openai_conversation/test_entity.py b/tests/components/openai_conversation/test_entity.py index ddd787ea9f09a5..2df0dcaa4a2bdc 100644 --- a/tests/components/openai_conversation/test_entity.py +++ b/tests/components/openai_conversation/test_entity.py @@ -5,14 +5,77 @@ import probatio import pytest +from syrupy.assertion import SnapshotAssertion +from homeassistant.components import conversation from homeassistant.components.openai_conversation.entity import ( + _convert_content_to_param, _format_structured_output, async_prepare_files_for_prompt, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import selector +from homeassistant.helpers import llm, selector +from homeassistant.util.json import JsonObjectType + + +@pytest.mark.parametrize( + ("code", "outputs", "error", "external", "status"), + [ + pytest.param(None, None, False, True, "completed", id="no_output"), + pytest.param( + "raise ValueError()", + [{"type": "logs", "logs": "ValueError"}], + True, + True, + "failed", + id="failed", + ), + pytest.param( + "plt.show()", + [{"type": "image", "url": "https://example.com/plot.png"}], + False, + True, + "completed", + id="image", + ), + pytest.param("print(1)", None, False, False, "completed", id="custom_function"), + pytest.param("print(1)", None, False, True, "incomplete", id="incomplete"), + ], +) +def test_convert_code_interpreter( + code: str | None, + outputs: list[JsonObjectType] | None, + error: bool, + external: bool, + status: str, + snapshot: SnapshotAssertion, +) -> None: + """Restore native external calls while preserving custom function calls.""" + content = [ + conversation.AssistantContent( + agent_id="conversation.openai_conversation", + tool_calls=[ + llm.ToolInput( + id="ci_A", + tool_name="code_interpreter", + tool_args={"code": code}, + external=external, + ) + ], + ), + conversation.ToolResultContent( + agent_id="conversation.openai_conversation", + tool_call_id="ci_A", + tool_name="code_interpreter", + result=llm.ToolResult( + data={"container_id": "cntr_A", "output": outputs, "status": status}, + error=error, + ), + ), + ] + + assert _convert_content_to_param(content) == snapshot async def test_format_structured_output() -> None: From 9ec49e85db1a1e759e6efb131a1f8fa2f8bec513 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 20 Sep 2026 11:29:01 -0400 Subject: [PATCH 2/8] Carry the remote tool metadata in mcp (#182712) Co-authored-by: Claude --- homeassistant/components/mcp/coordinator.py | 30 ++++++++++- tests/components/mcp/test_init.py | 60 ++++++++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mcp/coordinator.py b/homeassistant/components/mcp/coordinator.py index fb099a800530dd..87f251f20777e1 100644 --- a/homeassistant/components/mcp/coordinator.py +++ b/homeassistant/components/mcp/coordinator.py @@ -12,7 +12,7 @@ from mcp.client.session import ClientSession from mcp.client.sse import sse_client from mcp.client.streamable_http import streamable_http_client -from mcp.types import InitializeResult +from mcp.types import InitializeResult, ToolAnnotations import probatio # Imported by name because the tests patch it on this module. @@ -121,22 +121,48 @@ async def mcp_client( raise main_error from streamable_err +def _tool_annotations(remote: ToolAnnotations | None) -> llm.ToolAnnotations: + """Return the annotations the remote server declares for a tool. + + A hint the server leaves out keeps the conservative default. + """ + if remote is None: + return llm.ToolAnnotations() + declared = { + field: value + for field, value in ( + ("read_only", remote.readOnlyHint), + ("destructive", remote.destructiveHint), + ("idempotent", remote.idempotentHint), + ("open_world", remote.openWorldHint), + ) + if value is not None + } + return llm.ToolAnnotations(**declared) + + class ModelContextProtocolTool(llm.Tool): """A Tool exposed over the Model Context Protocol.""" + integration = DOMAIN + def __init__( self, name: str, + title: str | None, description: str | None, parameters: probatio.Schema, server_url: str, config_entry: ConfigEntry, token_manager: TokenManager | None = None, + annotations: llm.ToolAnnotations = llm.ToolAnnotations(), ) -> None: """Initialize the tool.""" self.name = name + self.title = title self.description = description self.parameters = parameters + self.annotations = annotations self.server_url = server_url self.config_entry = config_entry self.token_manager = token_manager @@ -262,11 +288,13 @@ async def _async_update_data(self) -> list[llm.Tool]: tools.append( ModelContextProtocolTool( tool.name, + tool.title, tool.description, parameters, self.config_entry.data[CONF_URL], self.config_entry, self.token_manager, + _tool_annotations(tool.annotations), ) ) return tools diff --git a/tests/components/mcp/test_init.py b/tests/components/mcp/test_init.py index 7e903e5066d29f..6988e10574b69c 100644 --- a/tests/components/mcp/test_init.py +++ b/tests/components/mcp/test_init.py @@ -6,7 +6,14 @@ import httpx from mcp import McpError -from mcp.types import CallToolResult, ErrorData, ListToolsResult, TextContent, Tool +from mcp.types import ( + CallToolResult, + ErrorData, + ListToolsResult, + TextContent, + Tool, + ToolAnnotations, +) import probatio import pytest @@ -317,6 +324,57 @@ async def test_llm_get_api_tools( } +@pytest.mark.parametrize( + ("remote_annotations", "expected_annotations"), + [ + pytest.param(None, llm.ToolAnnotations(), id="unannotated"), + pytest.param( + ToolAnnotations(readOnlyHint=True, openWorldHint=False), + llm.ToolAnnotations(read_only=True, open_world=False), + id="partly-annotated", + ), + pytest.param( + ToolAnnotations( + readOnlyHint=False, + destructiveHint=False, + idempotentHint=True, + openWorldHint=True, + ), + llm.ToolAnnotations(destructive=False, idempotent=True), + id="fully-annotated", + ), + ], +) +async def test_llm_tool_annotations( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, + remote_annotations: ToolAnnotations | None, + expected_annotations: llm.ToolAnnotations, +) -> None: + """Test the annotations the remote server declares are carried over.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[ + SEARCH_MEMORY_TOOL.model_copy( + update={"title": "Search memory", "annotations": remote_annotations} + ) + ] + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + api = next( + iter(api for api in llm.async_get_apis(hass) if api.name == TEST_API_NAME) + ) + api_instance = await api.async_get_api_instance(create_llm_context()) + tool = api_instance.tools[0] + + assert tool.integration == "mcp" + assert tool.title == "Search memory" + assert tool.annotations == expected_annotations + + @pytest.mark.parametrize( ("call_tool_result", "expected_result"), [ From 3593b874e070281eff937e470db013c4d19165b3 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sun, 20 Sep 2026 18:29:21 +0300 Subject: [PATCH 3/8] Use `safety_identifier` instead of deprecated `user` parameter for openai_conversation (#182695) --- homeassistant/components/openai_conversation/entity.py | 2 +- tests/components/openai_conversation/conftest.py | 2 +- tests/components/openai_conversation/test_conversation.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index dfe6cf4e7fc432..b5d303ed76599a 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -531,7 +531,7 @@ async def _async_handle_chat_log( # noqa: C901 model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), input=messages, max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), - user=chat_log.conversation_id, + safety_identifier=chat_log.conversation_id, prompt_cache_key=self.subentry.subentry_id, service_tier=options.get(CONF_SERVICE_TIER, RECOMMENDED_SERVICE_TIER), store=options.get(CONF_STORE_RESPONSES, RECOMMENDED_STORE_RESPONSES), diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index 06a6b316765ded..97c5146ae952ed 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -170,7 +170,7 @@ async def mock_generator(events, **kwargs): ), truncation=kwargs.get("truncation", "disabled"), usage=None, - user=kwargs.get("user"), + safety_identifier=kwargs.get("safety_identifier"), store=kwargs.get("store", True), ) yield ResponseCreatedEvent( diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 2817c269ffe5ed..723d3d03e8f1bc 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -917,6 +917,6 @@ async def test_model_args( model_args = mock_create_stream.call_args.kwargs.copy() model_args.pop("input") - assert model_args.pop("user") == result.conversation_id + assert model_args.pop("safety_identifier") == result.conversation_id assert model_args.pop("prompt_cache_key") == subentry.subentry_id assert model_args == snapshot From ea8ca3fe7e6eea6141b8678d8b102235959455c0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 20 Sep 2026 17:29:30 +0200 Subject: [PATCH 4/8] Fix remote calendar downloading and parsing its feed twice at setup (#182630) --- homeassistant/components/remote_calendar/calendar.py | 2 +- tests/components/remote_calendar/test_init.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/remote_calendar/calendar.py b/homeassistant/components/remote_calendar/calendar.py index 30cdd57e303b43..a5bfbab0954635 100644 --- a/homeassistant/components/remote_calendar/calendar.py +++ b/homeassistant/components/remote_calendar/calendar.py @@ -39,7 +39,7 @@ async def async_setup_entry( """Set up the remote calendar platform.""" coordinator = entry.runtime_data entity = RemoteCalendarEntity(coordinator, entry) - async_add_entities([entity], True) + async_add_entities([entity]) class RemoteCalendarEntity( diff --git a/tests/components/remote_calendar/test_init.py b/tests/components/remote_calendar/test_init.py index e1a14f9eca2faa..0acfe5d1599799 100644 --- a/tests/components/remote_calendar/test_init.py +++ b/tests/components/remote_calendar/test_init.py @@ -26,7 +26,7 @@ async def test_load_unload( hass: HomeAssistant, config_entry: MockConfigEntry, ics_content: str ) -> None: """Test loading and unloading a config entry.""" - respx.get(CALENDER_URL).mock( + route = respx.get(CALENDER_URL).mock( return_value=Response( status_code=200, text=ics_content, @@ -34,6 +34,8 @@ async def test_load_unload( ) await setup_integration(hass, config_entry) assert config_entry.state is ConfigEntryState.LOADED + # the calendar is downloaded and parsed once for the whole setup + assert route.call_count == 1 state = hass.states.get(TEST_ENTITY) assert state From bb56e6c5c8094e2af5f5ff01b824abd6fbd79558 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 20 Sep 2026 11:35:44 -0400 Subject: [PATCH 5/8] Set tool metadata in climate (#182701) Co-authored-by: Claude --- homeassistant/components/climate/llm.py | 27 +++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/climate/llm.py b/homeassistant/components/climate/llm.py index b42b6dea79f927..cf53b5be2a5e5f 100644 --- a/homeassistant/components/climate/llm.py +++ b/homeassistant/components/climate/llm.py @@ -4,12 +4,25 @@ from homeassistant.components.llm import LLMTools from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import intent -from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool +from homeassistant.helpers.llm import ( + LLM_API_ASSIST, + IntentTool, + LLMContext, + Tool, + ToolAnnotations, +) from .const import DOMAIN, INTENT_SET_TEMPERATURE -# Intents owned by this integration that are exposed as LLM tools. -LLM_INTENTS = (INTENT_SET_TEMPERATURE,) +# Each intent sets a value on the user's own entities, so calling one again +# with the same arguments has no further effect. +LLM_ANNOTATIONS = ToolAnnotations(idempotent=True, open_world=False) + +# Intents owned by this integration that are exposed as LLM tools, with the +# title shown for each. +LLM_INTENTS = { + INTENT_SET_TEMPERATURE: "Set temperature", +} @callback @@ -30,7 +43,13 @@ def async_get_tools( return None tools: list[Tool] = [ - IntentTool(f"{DOMAIN}__{handler.intent_type}", handler) + IntentTool( + f"{DOMAIN}__{handler.intent_type}", + handler, + title=LLM_INTENTS[handler.intent_type], + integration=DOMAIN, + annotations=LLM_ANNOTATIONS, + ) for handler in intent.async_get(hass) if handler.intent_type in LLM_INTENTS ] From 7e197f45163ad03a616c2942f012b83f1f978547 Mon Sep 17 00:00:00 2001 From: Thomas <10558666+mosandlt@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:45:46 +0200 Subject: [PATCH 6/8] bosch_shc: add presence simulation switch entity (#182335) Co-authored-by: Claude Sonnet 5 --- homeassistant/components/bosch_shc/switch.py | 19 +++++ tests/components/bosch_shc/conftest.py | 38 +++++++++- tests/components/bosch_shc/test_switch.py | 77 ++++++++++++++++++++ 3 files changed, 130 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py index 13c8b71a38da30..f1949dfd148076 100644 --- a/homeassistant/components/bosch_shc/switch.py +++ b/homeassistant/components/bosch_shc/switch.py @@ -91,6 +91,13 @@ class SHCSwitchEntityDescription(SwitchEntityDescription): on_value=ThermostatService.State.ON, should_poll=False, ), + "presencesimulation": SHCSwitchEntityDescription( + key="presencesimulation", + device_class=SwitchDeviceClass.SWITCH, + on_key="enabled", + on_value=True, + should_poll=False, + ), } @@ -207,6 +214,18 @@ async def async_setup_entry( ) ) + presence_simulation_system = session.device_helper.presence_simulation_system + if presence_simulation_system is not None: + entities.append( + SHCSwitch( + hass=hass, + device=presence_simulation_system, + parent_id=shc_info.unique_id, + entry_id=config_entry.entry_id, + description=SWITCH_TYPES["presencesimulation"], + ) + ) + async_add_entities(entities) diff --git a/tests/components/bosch_shc/conftest.py b/tests/components/bosch_shc/conftest.py index ab3d7aa2b99152..c8cf981a38dd55 100644 --- a/tests/components/bosch_shc/conftest.py +++ b/tests/components/bosch_shc/conftest.py @@ -12,11 +12,13 @@ SHCLightSwitchBSM, SHCMicromoduleBlinds, SHCMicromoduleRelay, + SHCPresenceSimulationSystem, SHCShutterControl, SHCThermostat, ShutterControlService, ThermostatService, ) +from boschshcpy.services_impl import PresenceSimulationConfigurationService import pytest from homeassistant.components.bosch_shc.const import ( @@ -51,7 +53,7 @@ def mock_config_entry() -> MockConfigEntry: # Keep in sync with binary_sensor.py's device_helper buckets — a bucket # missing here breaks the mock_session fixture. -_EMPTY_DEVICE_BUCKETS: dict[str, list[Any]] = { +_EMPTY_DEVICE_BUCKETS: dict[str, Any] = { bucket: [] for bucket in ( "camera_360", @@ -77,22 +79,26 @@ def mock_config_entry() -> MockConfigEntry: "wallthermostats", "water_leakage_detectors", ) +} | { + # Not a list bucket — presence_simulation_system is a single optional + # device on device_helper, not a device_helper. list of devices. + "presence_simulation_system": None, } @pytest.fixture -def device_buckets(request: pytest.FixtureRequest) -> dict[str, list[Any]]: +def device_buckets(request: pytest.FixtureRequest) -> dict[str, Any]: """device_helper buckets for the mock session. Empty by default; a test overrides specific buckets via ``@pytest.mark.parametrize("device_buckets", [{...}], indirect=True)``. """ - overrides: dict[str, list[Any]] = getattr(request, "param", {}) + overrides: dict[str, Any] = getattr(request, "param", {}) return {**_EMPTY_DEVICE_BUCKETS, **overrides} @pytest.fixture -def mock_session(device_buckets: dict[str, list[Any]]) -> Generator[MagicMock]: +def mock_session(device_buckets: dict[str, Any]) -> Generator[MagicMock]: """Mock SHCSession, patched in for the duration of the test.""" session = MagicMock() session.information.unique_id = "test-mac" @@ -248,3 +254,27 @@ def light_switch_bsm_device( device.switchstate = PowerSwitchService.State.OFF device.child_lock = child_lock return device + + +def presence_simulation_system_device( + device_id: str = "presenceSimulationService", + name: str = "Presence Simulation", + enabled: bool = False, +) -> SHCPresenceSimulationSystem: + """Build a minimal device double for the presence_simulation_system slot.""" + device = create_autospec(SHCPresenceSimulationSystem, instance=True, spec_set=True) + device.name = name + device.id = device_id + device.root_device_id = "test-mac" + device.serial = f"serial-{device_id}" + device.manufacturer = "Bosch" + device.device_model = "PRESENCE_SIMULATION_SERVICE" + device.device_services = [ + create_autospec( + PresenceSimulationConfigurationService, instance=True, spec_set=True + ) + ] + device.deleted = False + device.status = "AVAILABLE" + device.enabled = enabled + return device diff --git a/tests/components/bosch_shc/test_switch.py b/tests/components/bosch_shc/test_switch.py index 903efc30da9767..4955107cc7b4fa 100644 --- a/tests/components/bosch_shc/test_switch.py +++ b/tests/components/bosch_shc/test_switch.py @@ -19,6 +19,7 @@ from .conftest import ( light_switch_bsm_device, micromodule_relay_device, + presence_simulation_system_device, setup_integration, thermostat_device, ) @@ -116,3 +117,79 @@ async def test_light_switch_bsm_child_lock_unique_id( assert lightswitch_entry is not None assert child_lock_entry is not None assert lightswitch_entry.unique_id != child_lock_entry.unique_id + + +@pytest.mark.parametrize( + "device_buckets", + [{"presence_simulation_system": presence_simulation_system_device(enabled=False)}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_presence_simulation_system( + hass: HomeAssistant, + mock_session: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """The presence simulation system is exposed and controllable as a switch.""" + await setup_integration(hass, mock_config_entry) + device = mock_session.device_helper.presence_simulation_system + + state = hass.states.get("switch.presence_simulation") + assert state is not None + assert state.state == "off" + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.presence_simulation"}, + blocking=True, + ) + assert device.enabled is True + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.presence_simulation"}, + blocking=True, + ) + assert device.enabled is False + + +@pytest.mark.parametrize( + "device_buckets", + [{"presence_simulation_system": presence_simulation_system_device(enabled=False)}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_presence_simulation_system_push_update( + hass: HomeAssistant, + mock_session: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A controller-side push update reaches the switch without polling.""" + await setup_integration(hass, mock_config_entry) + device = mock_session.device_helper.presence_simulation_system + service = device.device_services[0] + on_state_changed = service.subscribe_callback.call_args.args[1] + + device.enabled = True + on_state_changed() + await hass.async_block_till_done() + + assert hass.states.get("switch.presence_simulation").state == "on" + + +@pytest.mark.parametrize( + "device_buckets", + [{"presence_simulation_system": None}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_no_presence_simulation_system( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """No switch is created when the controller has no presence simulation system.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("switch.presence_simulation") is None From aab7a43d3d6c11c62b0815cb0eee511c888dd309 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 20 Sep 2026 17:51:14 +0200 Subject: [PATCH 7/8] Add stale device removal to Mikrotik (#181760) --- homeassistant/components/mikrotik/__init__.py | 31 +++++- .../components/mikrotik/quality_scale.yaml | 2 +- tests/components/mikrotik/__init__.py | 35 ++++-- tests/components/mikrotik/test_init.py | 105 +++++++++++++++++- 4 files changed, 158 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py index d8157f757d62b1..dda1db9b93bdb0 100644 --- a/homeassistant/components/mikrotik/__init__.py +++ b/homeassistant/components/mikrotik/__init__.py @@ -5,8 +5,9 @@ from librouteros import Api from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr +from homeassistant.util import slugify from .const import ATTR_MANUFACTURER, DOMAIN from .coordinator import ( @@ -56,6 +57,34 @@ async def async_setup_entry( sw_version=coordinator.firmware, ) + @callback + def _async_remove_stale_devices() -> None: + """Remove interface devices the hub no longer reports.""" + known_identifiers = {(DOMAIN, coordinator.serial_num)} + for interface in coordinator.api.interfaces: + if (mac := interface.get("mac-address")) and ( + name := interface.get("name") + ): + known_identifiers.add((DOMAIN, f"{slugify(mac)}_{name}")) + + for device_entry in dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ): + own_identifiers = { + identifier + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + } + # device-tracker clients are linked by MAC connection only, so an + # entry without an own identifier is never an interface device + if own_identifiers and own_identifiers.isdisjoint(known_identifiers): + device_registry.async_remove_device(device_entry.id) + + _async_remove_stale_devices() + config_entry.async_on_unload( + coordinator.async_add_listener(_async_remove_stale_devices) + ) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True diff --git a/homeassistant/components/mikrotik/quality_scale.yaml b/homeassistant/components/mikrotik/quality_scale.yaml index 898c0f804d04c2..fab069b2c67b32 100644 --- a/homeassistant/components/mikrotik/quality_scale.yaml +++ b/homeassistant/components/mikrotik/quality_scale.yaml @@ -70,7 +70,7 @@ rules: repair-issues: status: exempt comment: no known use cases for repair issues or flows, yet - stale-devices: todo + stale-devices: done # Platinum async-dependency: todo diff --git a/tests/components/mikrotik/__init__.py b/tests/components/mikrotik/__init__.py index 2391545725c856..5d6fc0443b959c 100644 --- a/tests/components/mikrotik/__init__.py +++ b/tests/components/mikrotik/__init__.py @@ -1,5 +1,6 @@ """Tests for the Mikrotik integration.""" +from collections.abc import Callable from typing import Any from unittest.mock import patch @@ -67,14 +68,12 @@ def _build_command_responses( } -async def setup_integration( - hass: HomeAssistant, - config_entry: MockConfigEntry, - *, - command_responses: dict[str, Any], -) -> None: - """Set up the component with mocked Mikrotik command responses.""" - config_entry.add_to_hass(hass) +def build_mock_command(responses: dict[str, Any]) -> Callable[..., Any]: + """Build a ``MikrotikData.command`` replacement from a command/response map. + + Any command missing from ``responses`` returns an empty dict, matching the + hub returning no rows for that service. + """ def mock_command( self, @@ -83,9 +82,25 @@ def mock_command( suppress_errors: bool = False, during_setup: bool = False, ) -> Any: - return command_responses.get(cmd, {}) + return responses.get(cmd, {}) + + return mock_command + + +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + *, + command_responses: dict[str, Any], +) -> None: + """Set up the component with mocked Mikrotik command responses.""" + config_entry.add_to_hass(hass) - with patch.object(mikrotik.coordinator.MikrotikData, "command", new=mock_command): + with patch.object( + mikrotik.coordinator.MikrotikData, + "command", + new=build_mock_command(command_responses), + ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/mikrotik/test_init.py b/tests/components/mikrotik/test_init.py index 67a5a6b665b88c..1e833aebc9a427 100644 --- a/tests/components/mikrotik/test_init.py +++ b/tests/components/mikrotik/test_init.py @@ -9,12 +9,15 @@ from librouteros.exceptions import ConnectionClosed, LibRouterosError import pytest +from homeassistant.components import mikrotik from homeassistant.components.mikrotik.const import ( ARP, CONF_ARP_PING, CONF_FORCE_DHCP, DHCP, + DOMAIN, IDENTITY, + INTERFACE, MIKROTIK_SERVICES, PING, ROUTERBOARD, @@ -22,12 +25,23 @@ from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import CONF_VERIFY_SSL from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import UpdateFailed -from homeassistant.util import dt as dt_util +from homeassistant.util import dt as dt_util, slugify -from . import setup_integration +from . import build_mock_command, setup_integration, setup_mikrotik_entry from .conftest import MockConfigEntryFactory -from .const import ARP_DATA, DHCP_DATA, MOCK_DATA +from .const import ( + ARP_DATA, + BRIDGE1_INTERFACE, + DHCP_DATA, + ETHER1_INTERFACE, + INTERFACE_DATA, + MOCK_DATA, + ROUTERBOARD_DATA, + TEST_SERIAL_NUMBER, + WLAN1_INTERFACE, +) from tests.common import async_fire_time_changed @@ -36,6 +50,11 @@ } +def _interface_identifier(interface: dict[str, Any]) -> tuple[str, str]: + """Return the device registry identifier used for an interface.""" + return (DOMAIN, f"{slugify(interface['mac-address'])}_{interface['name']}") + + def _command_side_effect( error_cmd: str, error: Exception ) -> Callable[..., list[dict[str, Any]]]: @@ -355,3 +374,83 @@ async def test_unload_entry( assert entry.state is ConfigEntryState.NOT_LOADED mock_api.close.assert_called_once() + + +async def test_stale_interface_devices_are_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntryFactory, +) -> None: + """Test interface devices missing from the hub data are removed on setup.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + + stale_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "0a_0b_0c_0d_0e_0f_wlan9")}, + ) + # a device-tracker client is linked by MAC connection only and must survive + client_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:09")}, + ) + + command = build_mock_command( + { + MIKROTIK_SERVICES[IDENTITY]: [{"name": "Mikrotik"}], + MIKROTIK_SERVICES[ROUTERBOARD]: ROUTERBOARD_DATA, + MIKROTIK_SERVICES[INTERFACE]: INTERFACE_DATA, + } + ) + + with patch.object(mikrotik.coordinator.MikrotikData, "command", new=command): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert device_registry.async_get(stale_device.id) is None + assert device_registry.async_get(client_device.id) is not None + assert ( + device_registry.async_get_device_by_identifier( + (DOMAIN, TEST_SERIAL_NUMBER), config_entry_id=entry.entry_id + ) + is not None + ) + assert ( + device_registry.async_get_device_by_identifier( + _interface_identifier(ETHER1_INTERFACE), config_entry_id=entry.entry_id + ) + is not None + ) + + +async def test_stale_interface_device_removed_on_coordinator_update( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test an interface device is removed once the hub stops reporting it.""" + config_entry = await setup_mikrotik_entry(hass, interface_data=INTERFACE_DATA) + + wlan1_identifier = _interface_identifier(WLAN1_INTERFACE) + assert device_registry.async_get_device_by_identifier( + wlan1_identifier, config_entry_id=config_entry.entry_id + ) + + command = build_mock_command( + {MIKROTIK_SERVICES[INTERFACE]: [ETHER1_INTERFACE, BRIDGE1_INTERFACE]} + ) + + with patch.object(mikrotik.coordinator.MikrotikData, "command", new=command): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + device_registry.async_get_device_by_identifier( + wlan1_identifier, config_entry_id=config_entry.entry_id + ) + is None + ) + assert device_registry.async_get_device_by_identifier( + _interface_identifier(ETHER1_INTERFACE), config_entry_id=config_entry.entry_id + ) + assert device_registry.async_get_device_by_identifier( + (DOMAIN, TEST_SERIAL_NUMBER), config_entry_id=config_entry.entry_id + ) From bcc8986d6ede598c9206df8d17022650b7f939ee Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Sep 2026 20:16:11 +0200 Subject: [PATCH 8/8] Remove deprecated hass arg from service methods (#182760) --- homeassistant/helpers/service.py | 6 -- tests/helpers/test_service.py | 125 +++++++++++++++---------------- 2 files changed, 59 insertions(+), 72 deletions(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 00c8904136b7ad..47ab34d1c06344 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -59,7 +59,6 @@ target as target_helpers, template, ) -from .deprecation import deprecated_hass_argument from .selector import TargetSelector from .typing import ConfigType, TemplateVarsType, VolDictType, VolSchemaType @@ -348,7 +347,6 @@ def async_prepare_call_from_config( } -@deprecated_hass_argument(breaks_in_ha_version="2026.10") def extract_entity_ids( service_call: ServiceCall, expand_group: bool = True ) -> set[str]: @@ -361,7 +359,6 @@ def extract_entity_ids( ).result() -@deprecated_hass_argument(breaks_in_ha_version="2026.10") async def async_extract_entities[_EntityT: Entity]( entities: Iterable[_EntityT], service_call: ServiceCall, @@ -400,7 +397,6 @@ async def async_extract_entities[_EntityT: Entity]( return found -@deprecated_hass_argument(breaks_in_ha_version="2026.10") async def async_extract_entity_ids( service_call: ServiceCall, expand_group: bool = True ) -> set[str]: @@ -415,7 +411,6 @@ async def async_extract_entity_ids( return referenced.referenced | referenced.indirectly_referenced -@deprecated_hass_argument(breaks_in_ha_version="2026.10") async def async_extract_config_entry_ids( service_call: ServiceCall, expand_group: bool = True ) -> set[str]: @@ -1025,7 +1020,6 @@ def async_register_admin_service( ) -@deprecated_hass_argument(breaks_in_ha_version="2026.10") @callback def verify_domain_control( domain: str, diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index df97cbf98433ea..9b89d2fe32e3d4 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -724,21 +724,20 @@ async def test_extract_entity_ids(hass: HomeAssistant) -> None: call = ServiceCall(hass, "light", "turn_on", {ATTR_ENTITY_ID: "light.Bowl"}) - assert {"light.bowl"} == await service.async_extract_entity_ids(hass, call) + assert {"light.bowl"} == await service.async_extract_entity_ids(call) call = ServiceCall(hass, "light", "turn_on", {ATTR_ENTITY_ID: "group.test"}) assert {"light.ceiling", "light.kitchen"} == await service.async_extract_entity_ids( - hass, call + call ) assert {"group.test"} == await service.async_extract_entity_ids( - hass, call, expand_group=False + call, expand_group=False ) assert ( await service.async_extract_entity_ids( - hass, ServiceCall(hass, "light", "turn_on", {ATTR_ENTITY_ID: ENTITY_MATCH_NONE}), ) == set() @@ -753,14 +752,14 @@ async def test_extract_entity_ids_from_area( assert { "light.in_own_area", - } == await service.async_extract_entity_ids(hass, call) + } == await service.async_extract_entity_ids(call) call = ServiceCall(hass, "light", "turn_on", {"area_id": "test-area"}) assert { "light.in_area", "light.assigned_to_area", - } == await service.async_extract_entity_ids(hass, call) + } == await service.async_extract_entity_ids(call) call = ServiceCall( hass, "light", "turn_on", {"area_id": ["test-area", "diff-area"]} @@ -770,11 +769,11 @@ async def test_extract_entity_ids_from_area( "light.in_area", "light.diff_area", "light.assigned_to_area", - } == await service.async_extract_entity_ids(hass, call) + } == await service.async_extract_entity_ids(call) assert ( await service.async_extract_entity_ids( - hass, ServiceCall(hass, "light", "turn_on", {"area_id": ENTITY_MATCH_NONE}) + ServiceCall(hass, "light", "turn_on", {"area_id": ENTITY_MATCH_NONE}) ) == set() ) @@ -785,13 +784,13 @@ async def test_extract_entity_ids_from_devices( ) -> None: """Test extract_entity_ids method with devices.""" assert await service.async_extract_entity_ids( - hass, ServiceCall(hass, "light", "turn_on", {"device_id": "device-no-area-id"}) + ServiceCall(hass, "light", "turn_on", {"device_id": "device-no-area-id"}) ) == { "light.no_area", } assert await service.async_extract_entity_ids( - hass, ServiceCall(hass, "light", "turn_on", {"device_id": "device-area-a-id"}) + ServiceCall(hass, "light", "turn_on", {"device_id": "device-area-a-id"}) ) == { "light.in_area_a", "light.in_area_b", @@ -799,7 +798,6 @@ async def test_extract_entity_ids_from_devices( assert ( await service.async_extract_entity_ids( - hass, ServiceCall(hass, "light", "turn_on", {"device_id": "non-existing-id"}), ) == set() @@ -814,7 +812,7 @@ async def test_extract_entity_ids_from_floor(hass: HomeAssistant) -> None: assert { "light.in_area", "light.assigned_to_area", - } == await service.async_extract_entity_ids(hass, call) + } == await service.async_extract_entity_ids(call) call = ServiceCall( hass, "light", "turn_on", {"floor_id": ["test-floor", "floor-a"]} @@ -824,11 +822,11 @@ async def test_extract_entity_ids_from_floor(hass: HomeAssistant) -> None: "light.in_area", "light.assigned_to_area", "light.in_area_a", - } == await service.async_extract_entity_ids(hass, call) + } == await service.async_extract_entity_ids(call) assert ( await service.async_extract_entity_ids( - hass, ServiceCall(hass, "light", "turn_on", {"floor_id": ENTITY_MATCH_NONE}) + ServiceCall(hass, "light", "turn_on", {"floor_id": ENTITY_MATCH_NONE}) ) == set() ) @@ -843,7 +841,7 @@ async def test_extract_entity_ids_from_labels(hass: HomeAssistant) -> None: "light.with_my_label", "light.config_with_my_label", "light.diag_with_my_label", - } == await service.async_extract_entity_ids(hass, call) + } == await service.async_extract_entity_ids(call) call = ServiceCall(hass, "light", "turn_on", {"label_id": "label1"}) @@ -852,24 +850,24 @@ async def test_extract_entity_ids_from_labels(hass: HomeAssistant) -> None: "light.with_label1_from_device_diff_area", "light.with_labels_from_device", "light.with_label1_and_label2_from_device", - } == await service.async_extract_entity_ids(hass, call) + } == await service.async_extract_entity_ids(call) call = ServiceCall(hass, "light", "turn_on", {"label_id": ["label2"]}) assert { "light.with_labels_from_device", "light.with_label1_and_label2_from_device", - } == await service.async_extract_entity_ids(hass, call) + } == await service.async_extract_entity_ids(call) call = ServiceCall(hass, "light", "turn_on", {"label_id": ["label_area"]}) assert { "light.with_labels_from_device", - } == await service.async_extract_entity_ids(hass, call) + } == await service.async_extract_entity_ids(call) assert ( await service.async_extract_entity_ids( - hass, ServiceCall(hass, "light", "turn_on", {"label_id": ENTITY_MATCH_NONE}) + ServiceCall(hass, "light", "turn_on", {"label_id": ENTITY_MATCH_NONE}) ) == set() ) @@ -2086,25 +2084,21 @@ async def mock_service(call: ServiceCall) -> ServiceResponse: assert result == {"test-reply": "test-value1"} -_DEPRECATED_VERIFY_DOMAIN_CONTROL_MESSAGE = ( - "The deprecated argument hass was passed to verify_domain_control. It will be" - " removed in HA Core 2026.10. Use verify_domain_control without hass argument" - " instead" -) - - @pytest.mark.parametrize( # Check that with or without hass behaves the same ("decorator", "in_caplog"), [ (service.verify_domain_control, True), # old pass-through - (lambda _, domain: service.verify_domain_control(domain), False), # new + ( + lambda domain: service.verify_domain_control(domain), # pylint: disable=unnecessary-lambda + False, + ), # new ], ) async def test_domain_control_not_async( hass: HomeAssistant, mock_entities, - decorator: Callable[[HomeAssistant, str], Any], + decorator: Callable[[str], Any], in_caplog: bool, caplog: pytest.LogCaptureFixture, ) -> None: @@ -2116,9 +2110,7 @@ def mock_service_log(call): calls.append(call) with pytest.raises(exceptions.HomeAssistantError): - decorator(hass, "test_domain")(mock_service_log) - - assert (_DEPRECATED_VERIFY_DOMAIN_CONTROL_MESSAGE in caplog.text) == in_caplog + decorator("test_domain")(mock_service_log) @pytest.mark.parametrize( @@ -2126,7 +2118,10 @@ def mock_service_log(call): ("decorator", "in_caplog"), [ (service.verify_domain_control, True), # old pass-through - (lambda _, domain: service.verify_domain_control(domain), False), # new + ( + lambda domain: service.verify_domain_control(domain), # pylint: disable=unnecessary-lambda + False, + ), # new ], ) async def test_domain_control_unknown( @@ -2147,7 +2142,7 @@ async def mock_service_log(call): "homeassistant.helpers.entity_registry.async_get", return_value=Mock(entities=mock_entities), ): - protected_mock_service = decorator(hass, "test_domain")(mock_service_log) + protected_mock_service = decorator("test_domain")(mock_service_log) hass.services.async_register( "test_domain", "test_service", protected_mock_service, schema=None @@ -2163,21 +2158,22 @@ async def mock_service_log(call): ) assert len(calls) == 0 - assert (_DEPRECATED_VERIFY_DOMAIN_CONTROL_MESSAGE in caplog.text) == in_caplog - @pytest.mark.parametrize( # Check that with or without hass behaves the same ("decorator", "in_caplog"), [ (service.verify_domain_control, True), # old pass-through - (lambda _, domain: service.verify_domain_control(domain), False), # new + ( + lambda domain: service.verify_domain_control(domain), # pylint: disable=unnecessary-lambda + False, + ), # new ], ) async def test_domain_control_unauthorized( hass: HomeAssistant, hass_read_only_user: MockUser, - decorator: Callable[[HomeAssistant, str], Any], + decorator: Callable[[str], Any], in_caplog: bool, caplog: pytest.LogCaptureFixture, ) -> None: @@ -2199,7 +2195,7 @@ async def mock_service_log(call): """Define a protected service.""" calls.append(call) - protected_mock_service = decorator(hass, "test_domain")(mock_service_log) + protected_mock_service = decorator("test_domain")(mock_service_log) hass.services.async_register( "test_domain", "test_service", protected_mock_service, schema=None @@ -2216,21 +2212,22 @@ async def mock_service_log(call): assert len(calls) == 0 - assert (_DEPRECATED_VERIFY_DOMAIN_CONTROL_MESSAGE in caplog.text) == in_caplog - @pytest.mark.parametrize( # Check that with or without hass behaves the same ("decorator", "in_caplog"), [ (service.verify_domain_control, True), # old pass-through - (lambda _, domain: service.verify_domain_control(domain), False), # new + ( + lambda domain: service.verify_domain_control(domain), # pylint: disable=unnecessary-lambda + False, + ), # new ], ) async def test_domain_control_admin( hass: HomeAssistant, hass_admin_user: MockUser, - decorator: Callable[[HomeAssistant, str], Any], + decorator: Callable[[str], Any], in_caplog: bool, caplog: pytest.LogCaptureFixture, ) -> None: @@ -2252,7 +2249,7 @@ async def mock_service_log(call): """Define a protected service.""" calls.append(call) - protected_mock_service = decorator(hass, "test_domain")(mock_service_log) + protected_mock_service = decorator("test_domain")(mock_service_log) hass.services.async_register( "test_domain", "test_service", protected_mock_service, schema=None @@ -2268,20 +2265,21 @@ async def mock_service_log(call): assert len(calls) == 1 - assert (_DEPRECATED_VERIFY_DOMAIN_CONTROL_MESSAGE in caplog.text) == in_caplog - @pytest.mark.parametrize( # Check that with or without hass behaves the same ("decorator", "in_caplog"), [ (service.verify_domain_control, True), # old pass-through - (lambda _, domain: service.verify_domain_control(domain), False), # new + ( + lambda domain: service.verify_domain_control(domain), # pylint: disable=unnecessary-lambda + False, + ), # new ], ) async def test_domain_control_no_user( hass: HomeAssistant, - decorator: Callable[[HomeAssistant, str], Any], + decorator: Callable[[str], Any], in_caplog: bool, caplog: pytest.LogCaptureFixture, ) -> None: @@ -2303,7 +2301,7 @@ async def mock_service_log(call): """Define a protected service.""" calls.append(call) - protected_mock_service = decorator(hass, "test_domain")(mock_service_log) + protected_mock_service = decorator("test_domain")(mock_service_log) hass.services.async_register( "test_domain", "test_service", protected_mock_service, schema=None @@ -2319,8 +2317,6 @@ async def mock_service_log(call): assert len(calls) == 1 - assert (_DEPRECATED_VERIFY_DOMAIN_CONTROL_MESSAGE in caplog.text) == in_caplog - async def test_extract_from_service_available_device(hass: HomeAssistant) -> None: """Test the extraction of entity from service and device is available.""" @@ -2335,7 +2331,7 @@ async def test_extract_from_service_available_device(hass: HomeAssistant) -> Non assert [ ent.entity_id - for ent in (await service.async_extract_entities(hass, entities, call_1)) + for ent in (await service.async_extract_entities(entities, call_1)) ] == ["test_domain.test_1", "test_domain.test_3"] call_2 = ServiceCall( @@ -2347,12 +2343,11 @@ async def test_extract_from_service_available_device(hass: HomeAssistant) -> Non assert [ ent.entity_id - for ent in (await service.async_extract_entities(hass, entities, call_2)) + for ent in (await service.async_extract_entities(entities, call_2)) ] == ["test_domain.test_3"] assert ( await service.async_extract_entities( - hass, entities, ServiceCall( hass, @@ -2374,8 +2369,7 @@ async def test_extract_from_service_empty_if_no_entity_id(hass: HomeAssistant) - call = ServiceCall(hass, "test", "service") assert [ - ent.entity_id - for ent in (await service.async_extract_entities(hass, entities, call)) + ent.entity_id for ent in (await service.async_extract_entities(entities, call)) ] == [] @@ -2396,8 +2390,7 @@ async def test_extract_from_service_filter_out_non_existing_entities( ) assert [ - ent.entity_id - for ent in (await service.async_extract_entities(hass, entities, call)) + ent.entity_id for ent in (await service.async_extract_entities(entities, call)) ] == ["test_domain.test_2"] @@ -2412,14 +2405,14 @@ async def test_extract_from_service_area_id( ] call = ServiceCall(hass, "light", "turn_on", {"area_id": "test-area"}) - extracted = await service.async_extract_entities(hass, entities, call) + extracted = await service.async_extract_entities(entities, call) assert len(extracted) == 1 assert extracted[0].entity_id == "light.in_area" call = ServiceCall( hass, "light", "turn_on", {"area_id": ["test-area", "diff-area"]} ) - extracted = await service.async_extract_entities(hass, entities, call) + extracted = await service.async_extract_entities(entities, call) assert len(extracted) == 2 assert sorted(ent.entity_id for ent in extracted) == [ "light.diff_area", @@ -2432,7 +2425,7 @@ async def test_extract_from_service_area_id( "turn_on", {"area_id": ["test-area", "diff-area"], "device_id": "device-no-area-id"}, ) - extracted = await service.async_extract_entities(hass, entities, call) + extracted = await service.async_extract_entities(entities, call) assert len(extracted) == 3 assert sorted(ent.entity_id for ent in extracted) == [ "light.diff_area", @@ -2453,17 +2446,17 @@ async def test_extract_from_service_label_id(hass: HomeAssistant) -> None: ] call = ServiceCall(hass, "light", "turn_on", {"label_id": "label_area"}) - extracted = await service.async_extract_entities(hass, entities, call) + extracted = await service.async_extract_entities(entities, call) assert len(extracted) == 1 assert extracted[0].entity_id == "light.with_labels_from_device" call = ServiceCall(hass, "light", "turn_on", {"label_id": "my-label"}) - extracted = await service.async_extract_entities(hass, entities, call) + extracted = await service.async_extract_entities(entities, call) assert len(extracted) == 1 assert extracted[0].entity_id == "light.with_my_label" call = ServiceCall(hass, "light", "turn_on", {"label_id": ["my-label", "label1"]}) - extracted = await service.async_extract_entities(hass, entities, call) + extracted = await service.async_extract_entities(entities, call) assert len(extracted) == 2 assert sorted(ent.entity_id for ent in extracted) == [ "light.with_labels_from_device", @@ -2476,7 +2469,7 @@ async def test_extract_from_service_label_id(hass: HomeAssistant) -> None: "turn_on", {"label_id": ["my-label", "label1"], "device_id": "device-no-labels"}, ) - extracted = await service.async_extract_entities(hass, entities, call) + extracted = await service.async_extract_entities(entities, call) assert len(extracted) == 3 assert sorted(ent.entity_id for ent in extracted) == [ "light.no_labels", @@ -2547,7 +2540,7 @@ async def test_async_extract_entities_warn_referenced( "label_id": "non-existent-label", }, ) - extracted = await service.async_extract_entities(hass, {}, call) + extracted = await service.async_extract_entities({}, call) assert len(extracted) == 0 assert ( "Referenced floors non-existent-floor, areas non-existent-area, " @@ -2580,7 +2573,7 @@ async def test_async_extract_config_entry_ids(hass: HomeAssistant) -> None: }, ) - assert await service.async_extract_config_entry_ids(hass, call) == {"abc"} + assert await service.async_extract_config_entry_ids(call) == {"abc"} async def test_reload_service_helper(hass: HomeAssistant) -> None: