From c9664a9ea810c9fcc01f6c592bbc1555357efd65 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 18 Sep 2026 11:35:48 -0400 Subject: [PATCH 01/21] Use ToolResult in ollama (#182548) Co-authored-by: Claude --- homeassistant/components/ollama/entity.py | 7 ++++++- tests/components/ollama/test_conversation.py | 5 +++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ollama/entity.py b/homeassistant/components/ollama/entity.py index 2b756d3343081..b3c2d2d795be0 100644 --- a/homeassistant/components/ollama/entity.py +++ b/homeassistant/components/ollama/entity.py @@ -94,7 +94,12 @@ def _convert_content( if isinstance(chat_content, conversation.ToolResultContent): return ollama.Message( role=MessageRole.TOOL.value, - content=json_dumps(chat_content.tool_result), + content=json_dumps( + { + "data": chat_content.result.data, + "error": chat_content.result.error, + } + ), ) if isinstance(chat_content, conversation.AssistantContent): return ollama.Message( diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index acfded42862ad..6a365d9a17c15 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -552,11 +552,12 @@ async def test_history_conversion( Message( role="tool", content=( - '{"speech":{"plain":{"speech":"4:24 PM",' + '{"data":{"speech":{"plain":{"speech":"4:24 PM",' '"extra_data":null}},' '"response_type":"action_done",' '"speech_slots":{"time":"16:24:17.813343"},' - '"data":{"success":[],"failed":[]}}' + '"data":{"success":[],"failed":[]}},' + '"error":false}' ), ), Message(role="assistant", content="4:24 PM"), From 6f9563fadd7704ce375ba06b90d232325316c6f2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 18 Sep 2026 17:41:03 +0200 Subject: [PATCH 02/21] Serve SVG media as attachments (#182095) --- .../components/media_source/const.py | 4 ++ .../components/media_source/local_source.py | 38 +++++++--- .../media_source/test_local_source.py | 70 +++++++++++++++++++ 3 files changed, 102 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/media_source/const.py b/homeassistant/components/media_source/const.py index e7cc4499b006b..9cda17e24503e 100644 --- a/homeassistant/components/media_source/const.py +++ b/homeassistant/components/media_source/const.py @@ -17,6 +17,10 @@ "media_source_platforms" ) MEDIA_MIME_TYPES = ("audio", "video", "image") +# Media types that pass the check above but are documents a browser executes. +# Serving these inline would run them on the Home Assistant origin, where the +# frontend keeps its tokens, so the browser is told to download them instead. +DOWNLOAD_ONLY_MIME_TYPES = {"image/svg+xml"} MEDIA_CLASS_MAP = { "audio": MediaClass.MUSIC, "video": MediaClass.VIDEO, diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index 9f60ea95808b1..104ee1eb59aca 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -7,7 +7,7 @@ import shutil from typing import Any, Protocol, cast, override -from aiohttp import web +from aiohttp import hdrs, web from aiohttp.web_request import FileField import probatio @@ -24,7 +24,13 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path -from .const import DATA_LOCAL_SOURCE, DOMAIN, MEDIA_CLASS_MAP, MEDIA_MIME_TYPES +from .const import ( + DATA_LOCAL_SOURCE, + DOMAIN, + DOWNLOAD_ONLY_MIME_TYPES, + MEDIA_CLASS_MAP, + MEDIA_MIME_TYPES, +) from .error import Unresolvable from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia @@ -349,6 +355,15 @@ def _build_item_response( return media +@callback +def _async_media_headers(mime_type: str) -> dict[str, str]: + """Return the headers a media file of this type is served with.""" + if mime_type in DOWNLOAD_ONLY_MIME_TYPES: + return {hdrs.CONTENT_DISPOSITION: "attachment"} + + return {} + + class LocalMediaView(http.HomeAssistantView): """Local Media Finder View. @@ -364,8 +379,10 @@ def __init__(self, hass: HomeAssistant, source: LocalSource) -> None: self.name = source.url_prefix.strip("/").replace("/", ":") self.url = f"{source.url_prefix}/{{source_dir_id}}/{{location:.*}}" - async def _validate_media_path(self, source_dir_id: str, location: str) -> Path: - """Validate media path and return it if valid.""" + async def _validate_media_path( + self, source_dir_id: str, location: str + ) -> tuple[Path, str]: + """Validate media path and return it with its media type if valid.""" try: raise_if_invalid_path(location) except ValueError as err: @@ -385,7 +402,7 @@ async def _validate_media_path(self, source_dir_id: str, location: str) -> Path: if not mime_type or mime_type.split("/")[0] not in MEDIA_MIME_TYPES: raise web.HTTPNotFound - return media_path + return media_path, mime_type async def head( self, request: web.Request, source_dir_id: str, location: str @@ -397,16 +414,17 @@ async def head( Check whether the location exists or not. """ - media_path = await self._validate_media_path(source_dir_id, location) - mime_type, _ = mimetypes.guess_type(str(media_path)) - return web.Response(content_type=mime_type) + _, mime_type = await self._validate_media_path(source_dir_id, location) + return web.Response( + content_type=mime_type, headers=_async_media_headers(mime_type) + ) async def get( self, request: web.Request, source_dir_id: str, location: str ) -> web.FileResponse: """Handle a GET request.""" - media_path = await self._validate_media_path(source_dir_id, location) - return web.FileResponse(media_path) + media_path, mime_type = await self._validate_media_path(source_dir_id, location) + return web.FileResponse(media_path, headers=_async_media_headers(mime_type)) class UploadMediaView(http.HomeAssistantView): diff --git a/tests/components/media_source/test_local_source.py b/tests/components/media_source/test_local_source.py index 47a7b030de79b..bd228c1e5903b 100644 --- a/tests/components/media_source/test_local_source.py +++ b/tests/components/media_source/test_local_source.py @@ -505,3 +505,73 @@ def create_file(): assert not msg["success"] assert to_delete_3.is_file() + + +@pytest.mark.parametrize( + ("filename", "content_type"), + [ + ("photo.jpg", "image/jpeg"), + ("song.mp3", "audio/mpeg"), + ("clip.mp4", "video/mp4"), + ], +) +async def test_media_view_serves_media_inline( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + tmp_path: Path, + filename: str, + content_type: str, +) -> None: + """Test ordinary media is served for the browser to render.""" + (tmp_path / filename).touch() + + await async_process_ha_core_config(hass, {"media_dirs": {"local": str(tmp_path)}}) + await hass.async_block_till_done() + + assert await async_setup_component(hass, const.DOMAIN, {}) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.get(f"/media/local/{filename}") + assert resp.status == HTTPStatus.OK + assert resp.content_type == content_type + assert "Content-Disposition" not in resp.headers + + resp = await client.head(f"/media/local/{filename}") + assert resp.status == HTTPStatus.OK + assert resp.content_type == content_type + assert "Content-Disposition" not in resp.headers + + +async def test_media_view_serves_svg_as_attachment( + hass: HomeAssistant, hass_client: ClientSessionGenerator, tmp_path: Path +) -> None: + """Test an SVG is downloaded rather than rendered. + + An SVG is a document a browser executes, and these are served from the + Home Assistant origin. + """ + (tmp_path / "drawing.svg").write_text( + '' + ) + + await async_process_ha_core_config(hass, {"media_dirs": {"local": str(tmp_path)}}) + await hass.async_block_till_done() + + assert await async_setup_component(hass, const.DOMAIN, {}) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.get("/media/local/drawing.svg") + assert resp.status == HTTPStatus.OK + assert resp.headers["Content-Disposition"] == "attachment" + # Applied to every response by the HTTP integration, asserted here because + # it is what keeps the type from being sniffed into something executable + assert resp.headers["X-Content-Type-Options"] == "nosniff" + + resp = await client.head("/media/local/drawing.svg") + assert resp.status == HTTPStatus.OK + assert resp.headers["Content-Disposition"] == "attachment" + assert resp.headers["X-Content-Type-Options"] == "nosniff" From b1f88df13b189cf27674ab30e340004efe618436 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 18 Sep 2026 11:43:26 -0400 Subject: [PATCH 03/21] Use ToolResult in llama_cpp (#182547) Co-authored-by: Claude --- homeassistant/components/llama_cpp/entity.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/llama_cpp/entity.py b/homeassistant/components/llama_cpp/entity.py index 5a9910f93baa4..dd8cac74468e3 100644 --- a/homeassistant/components/llama_cpp/entity.py +++ b/homeassistant/components/llama_cpp/entity.py @@ -104,7 +104,9 @@ 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( + {"data": content.result.data, "error": content.result.error} + ), ) role: Literal["user", "assistant", "system"] = content.role @@ -177,7 +179,9 @@ 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( + {"data": content.result.data, "error": content.result.error} + ), ) if not isinstance(content, conversation.AssistantContent) or not content.tool_calls: if isinstance(content, conversation.SystemContent): From a0eb7e30f01bbd60f9765ad0329575e654a88b0c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 18 Sep 2026 11:51:44 -0400 Subject: [PATCH 04/21] Use ToolResult in calendar (#182539) Co-authored-by: Claude --- homeassistant/components/calendar/llm.py | 15 ++++++--- tests/components/calendar/test_llm.py | 39 ++++++++++++------------ 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/calendar/llm.py b/homeassistant/components/calendar/llm.py index 8000eaad1a2a1..ca4afea6444a8 100644 --- a/homeassistant/components/calendar/llm.py +++ b/homeassistant/components/calendar/llm.py @@ -10,9 +10,14 @@ from homeassistant.components.llm import LLMTools from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er, intent -from homeassistant.helpers.llm import LLM_API_ASSIST, LLMContext, Tool, ToolInput +from homeassistant.helpers.llm import ( + LLM_API_ASSIST, + LLMContext, + Tool, + ToolInput, + ToolResult, +) from homeassistant.util import dt as dt_util -from homeassistant.util.json import JsonObjectType from . import SERVICE_GET_EVENTS from .const import DOMAIN @@ -40,7 +45,7 @@ def __init__(self, calendars: list[str]) -> None: @override async def async_call( self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext - ) -> JsonObjectType: + ) -> ToolResult: """Query a calendar.""" data = self.parameters(tool_input.tool_args) result = intent.async_match_targets( @@ -52,7 +57,7 @@ async def async_call( ), ) if not result.is_match: - return {"success": False, "error": "Calendar not found"} + return ToolResult(data={"error": "Calendar not found"}, error=True) entity_id = result.states[0].entity_id if data["range"] == "today": @@ -82,7 +87,7 @@ async def async_call( for event in cast(dict, service_result)[entity_id]["events"] ] - return {"success": True, "result": events} + return ToolResult(data={"events": events}) @callback diff --git a/tests/components/calendar/test_llm.py b/tests/components/calendar/test_llm.py index aab77f8f7211a..0cb64a47163ca 100644 --- a/tests/components/calendar/test_llm.py +++ b/tests/components/calendar/test_llm.py @@ -107,24 +107,25 @@ async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: "end_date_time": dt_util.start_of_local_day(now) + timedelta(days=1), } - assert response == { - "success": True, - "result": [ - { - "start": "2025-09-17", - "end": "2025-09-18", - "summary": "Home Assistant 12th birthday", - "description": "", - "all_day": True, - }, - { - "start": "2025-09-17T14:00:00-05:00", - "end": "2025-09-18T15:00:00-05:00", - "summary": "Champagne", - "description": "", - }, - ], - } + assert response == llm.ToolResult( + data={ + "events": [ + { + "start": "2025-09-17", + "end": "2025-09-18", + "summary": "Home Assistant 12th birthday", + "description": "", + "all_day": True, + }, + { + "start": "2025-09-17T14:00:00-05:00", + "end": "2025-09-18T15:00:00-05:00", + "summary": "Champagne", + "description": "", + }, + ] + } + ) # The "week" range searches seven days out. calls.clear() @@ -153,7 +154,7 @@ async def test_calendar_get_events_tool_not_found(hass: HomeAssistant) -> None: ), llm_context, ) - assert response == {"success": False, "error": "Calendar not found"} + assert response == llm.ToolResult(data={"error": "Calendar not found"}, error=True) async def test_calendar_get_events_tool_uses_aliases( From 9835762807adb0b8b74ba02eb05156187d82d19f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 18 Sep 2026 11:56:17 -0400 Subject: [PATCH 05/21] Use ToolResult in the LLM helper, llm and homeassistant (#182538) Co-authored-by: Claude --- homeassistant/components/homeassistant/llm.py | 32 ++-- homeassistant/components/llm/llm.py | 14 +- homeassistant/components/mcp_server/server.py | 2 +- homeassistant/helpers/llm.py | 8 +- tests/components/homeassistant/test_llm.py | 161 +++++++++--------- tests/components/llm/test_tools.py | 9 +- tests/components/script/test_llm.py | 2 +- tests/helpers/test_llm.py | 15 +- 8 files changed, 117 insertions(+), 126 deletions(-) diff --git a/homeassistant/components/homeassistant/llm.py b/homeassistant/components/homeassistant/llm.py index c2fbd6a3976b8..2dc81095cf5c0 100644 --- a/homeassistant/components/homeassistant/llm.py +++ b/homeassistant/components/homeassistant/llm.py @@ -22,9 +22,14 @@ entity_registry as er, intent, ) -from homeassistant.helpers.llm import LLM_API_ASSIST, LLMContext, Tool, ToolInput +from homeassistant.helpers.llm import ( + LLM_API_ASSIST, + LLMContext, + Tool, + ToolInput, + ToolResult, +) from homeassistant.util import dt as dt_util, yaml as yaml_util -from homeassistant.util.json import JsonObjectType from .exposed_entities import async_should_expose @@ -247,13 +252,13 @@ async def async_call( hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext, - ) -> JsonObjectType: + ) -> ToolResult: """Get the current state of exposed entities.""" args = self.parameters(tool_input.tool_args) exposed_entities = async_get_exposed_entities(hass, llm_context.assistant) if not exposed_entities: - return {"success": False, "error": NO_ENTITIES_PROMPT} + return ToolResult(data={"error": NO_ENTITIES_PROMPT}, error=True) name_filter = args.get("name") area_filter = args.get("area") @@ -290,12 +295,14 @@ async def async_call( ) if not match_result.is_match: - return { - "success": False, - "error": _live_context_match_error( - match_result, name_filter, area_filter, domain_filter - ), - } + return ToolResult( + data={ + "error": _live_context_match_error( + match_result, name_filter, area_filter, domain_filter + ) + }, + error=True, + ) matched_ids = {state.entity_id for state in match_result.states} entities = [ @@ -311,10 +318,7 @@ async def async_call( " and the devices in this smart home:", yaml_util.dump(entities), ] - return { - "success": True, - "result": "\n".join(prompt), - } + return ToolResult(data={"result": "\n".join(prompt)}) @callback diff --git a/homeassistant/components/llm/llm.py b/homeassistant/components/llm/llm.py index 8061a06ddad35..01a7a705dba8c 100644 --- a/homeassistant/components/llm/llm.py +++ b/homeassistant/components/llm/llm.py @@ -3,9 +3,8 @@ from typing import override from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.llm import LLMContext, Tool, ToolInput +from homeassistant.helpers.llm import LLMContext, Tool, ToolInput, ToolResult from homeassistant.util import dt as dt_util -from homeassistant.util.json import JsonObjectType from . import LLMTools @@ -22,19 +21,18 @@ async def async_call( hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext, - ) -> JsonObjectType: + ) -> ToolResult: """Get the current date and time.""" now = dt_util.now() - return { - "success": True, - "result": { + return ToolResult( + data={ "date": now.strftime("%Y-%m-%d"), "time": now.strftime("%H:%M:%S"), "timezone": now.strftime("%Z"), "weekday": now.strftime("%A"), - }, - } + } + ) @callback diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py index e99f6f24cc34f..25dc9a6f89b82 100644 --- a/homeassistant/components/mcp_server/server.py +++ b/homeassistant/components/mcp_server/server.py @@ -147,7 +147,7 @@ async def handle_read_resource(uri: AnyUrl) -> Sequence[ReadResourceContents]: tool_response = await llm_api.async_call_tool( llm.ToolInput(tool_name=LIVE_CONTEXT_TOOL_NAME, tool_args={}) ) - if not tool_response.data.get("success"): + if tool_response.error: raise HomeAssistantError(cast(str, tool_response.data["error"])) return [ diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 76c153bec2e11..ed96216781ce3 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -264,7 +264,7 @@ def __init__( @override async def async_call( self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext - ) -> JsonObjectType: + ) -> ToolResult: """Handle the intent.""" slots = { key: {"value": val} @@ -305,7 +305,7 @@ async def async_call( assistant=llm_context.assistant, device_id=llm_context.device_id, ) - return IntentResponseDict(intent_response) + return ToolResult(data=IntentResponseDict(intent_response)) class IntentResponseDict(dict): @@ -655,7 +655,7 @@ def __init__( @override async def async_call( self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext - ) -> JsonObjectType: + ) -> ToolResult: """Call the action.""" for field, validator in self.parameters.schema.items(): @@ -696,4 +696,4 @@ async def async_call( return_response=True, ) - return {"success": True, "result": result} + return ToolResult(data={"result": result}) diff --git a/tests/components/homeassistant/test_llm.py b/tests/components/homeassistant/test_llm.py index cee9388fe25ed..a568d79d34d57 100644 --- a/tests/components/homeassistant/test_llm.py +++ b/tests/components/homeassistant/test_llm.py @@ -83,7 +83,9 @@ async def test_get_live_context_no_exposed_entities(hass: HomeAssistant) -> None response = await tool.async_call( hass, llm.ToolInput("homeassistant__GetLiveContext", {}), llm_context ) - assert response == {"success": False, "error": ha_llm.NO_ENTITIES_PROMPT} + assert response == llm.ToolResult( + data={"error": ha_llm.NO_ENTITIES_PROMPT}, error=True + ) async def test_get_live_context_tool(hass: HomeAssistant) -> None: @@ -99,8 +101,8 @@ async def test_get_live_context_tool(hass: HomeAssistant) -> None: response = await tool.async_call( hass, llm.ToolInput("homeassistant__GetLiveContext", {}), llm_context ) - assert response["success"] is True - assert "Kitchen Light" in response["result"] + assert response.error is False + assert "Kitchen Light" in response.data["result"] async def test_get_exposed_entities_timestamp_conversion(hass: HomeAssistant) -> None: @@ -261,145 +263,142 @@ async def test_get_live_context_tool_filter( tools = await llm_component.async_get_tools(hass, llm_context, "assist") tool = next(t for t in tools.tools if t.name == "homeassistant__GetLiveContext") - async def _get_live_context(tool_args: dict) -> dict: + async def _get_live_context(tool_args: dict) -> llm.ToolResult: return await tool.async_call( hass, llm.ToolInput("homeassistant__GetLiveContext", tool_args), llm_context ) # Filter by area and domain (example 1) result = await _get_live_context({"area": "Office", "domain": "light"}) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Kitchen Light" not in result["result"] - assert "Office Switch" not in result["result"] - assert "Front Door" not in result["result"] + assert result.error is False + assert "Office Light" in result.data["result"] + assert "Kitchen Light" not in result.data["result"] + assert "Office Switch" not in result.data["result"] + assert "Front Door" not in result.data["result"] # Filter by name (example 2) result = await _get_live_context({"name": "Front Door"}) - assert result["success"] is True - assert "Front Door" in result["result"] - assert "Office Light" not in result["result"] - assert "Kitchen Light" not in result["result"] - assert "Office Switch" not in result["result"] + assert result.error is False + assert "Front Door" in result.data["result"] + assert "Office Light" not in result.data["result"] + assert "Kitchen Light" not in result.data["result"] + assert "Office Switch" not in result.data["result"] # Name filter is case insensitive result = await _get_live_context({"name": "front door"}) - assert result["success"] is True - assert "Front Door" in result["result"] + assert result.error is False + assert "Front Door" in result.data["result"] # Area filter matches area aliases result = await _get_live_context({"area": "workspace"}) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Office Switch" in result["result"] - assert "Kitchen Light" not in result["result"] - assert "Front Door" not in result["result"] + assert result.error is False + assert "Office Light" in result.data["result"] + assert "Office Switch" in result.data["result"] + assert "Kitchen Light" not in result.data["result"] + assert "Front Door" not in result.data["result"] # Domain filter accepts a list result = await _get_live_context({"domain": ["switch", "lock"]}) - assert result["success"] is True - assert "Office Switch" in result["result"] - assert "Front Door" in result["result"] - assert "Office Light" not in result["result"] - assert "Kitchen Light" not in result["result"] + assert result.error is False + assert "Office Switch" in result.data["result"] + assert "Front Door" in result.data["result"] + assert "Office Light" not in result.data["result"] + assert "Kitchen Light" not in result.data["result"] # Domain filter is case insensitive result = await _get_live_context({"domain": "Light"}) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Kitchen Light" in result["result"] - assert "Office Switch" not in result["result"] - assert "Front Door" not in result["result"] + assert result.error is False + assert "Office Light" in result.data["result"] + assert "Kitchen Light" in result.data["result"] + assert "Office Switch" not in result.data["result"] + assert "Front Door" not in result.data["result"] # No filters returns all exposed entities result = await _get_live_context({}) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Kitchen Light" in result["result"] - assert "Office Switch" in result["result"] - assert "Front Door" in result["result"] + assert result.error is False + assert "Office Light" in result.data["result"] + assert "Kitchen Light" in result.data["result"] + assert "Office Switch" in result.data["result"] + assert "Front Door" in result.data["result"] # Filter that matches nothing returns a descriptive error result = await _get_live_context({"name": "Does Not Exist"}) - assert result == { - "success": False, - "error": "No exposed entities matched name 'Does Not Exist'", - } + assert result == llm.ToolResult( + data={"error": "No exposed entities matched name 'Does Not Exist'"}, + error=True, + ) # Name filter strips surrounding whitespace result = await _get_live_context({"name": " Front Door "}) - assert result["success"] is True - assert "Front Door" in result["result"] + assert result.error is False + assert "Front Door" in result.data["result"] # Area filter strips surrounding whitespace result = await _get_live_context({"area": " Office "}) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Office Switch" in result["result"] - assert "Kitchen Light" not in result["result"] + assert result.error is False + assert "Office Light" in result.data["result"] + assert "Office Switch" in result.data["result"] + assert "Kitchen Light" not in result.data["result"] # Name filter accepts entity_id result = await _get_live_context({"name": office_light.entity_id}) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Kitchen Light" not in result["result"] - assert "Office Switch" not in result["result"] + assert result.error is False + assert "Office Light" in result.data["result"] + assert "Kitchen Light" not in result.data["result"] + assert "Office Switch" not in result.data["result"] # Area filter accepts area_id result = await _get_live_context({"area": office.id}) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Office Switch" in result["result"] - assert "Kitchen Light" not in result["result"] - assert "Front Door" not in result["result"] + assert result.error is False + assert "Office Light" in result.data["result"] + assert "Office Switch" in result.data["result"] + assert "Kitchen Light" not in result.data["result"] + assert "Front Door" not in result.data["result"] # Name filter matches entity aliases result = await _get_live_context({"name": "cooking lamp"}) - assert result["success"] is True - assert "Kitchen Light" in result["result"] - assert "Office Light" not in result["result"] + assert result.error is False + assert "Kitchen Light" in result.data["result"] + assert "Office Light" not in result.data["result"] # Combining name + area narrows the result result = await _get_live_context({"name": "Office Light", "area": "Office"}) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Office Switch" not in result["result"] + assert result.error is False + assert "Office Light" in result.data["result"] + assert "Office Switch" not in result.data["result"] # Combining name + area returns the failing constraint in the error result = await _get_live_context({"name": "Office Light", "area": "Kitchen"}) - assert result == { - "success": False, - "error": "No exposed entities found in area 'Kitchen'", - } + assert result == llm.ToolResult( + data={"error": "No exposed entities found in area 'Kitchen'"}, error=True + ) # Unknown area distinguishes "invalid area" from "no entities in area" result = await _get_live_context({"area": "Garage"}) - assert result == { - "success": False, - "error": "Area 'Garage' does not exist", - } + assert result == llm.ToolResult( + data={"error": "Area 'Garage' does not exist"}, error=True + ) # Unknown domain reports which domain(s) failed result = await _get_live_context({"domain": "fan"}) - assert result == { - "success": False, - "error": "No exposed entities found in domain(s): fan", - } + assert result == llm.ToolResult( + data={"error": "No exposed entities found in domain(s): fan"}, error=True + ) # Entities sharing a name are all returned rather than failing as an # ambiguous match, since this tool only returns context. result = await _get_live_context({"name": "AC"}) - assert result["success"] is True - assert result["result"].count("domain: climate") == 2 - assert "Office" in result["result"] - assert "Kitchen" in result["result"] + assert result.error is False + assert result.data["result"].count("domain: climate") == 2 + assert "Office" in result.data["result"] + assert "Kitchen" in result.data["result"] # Combining a shared name with an area narrows to the single match result = await _get_live_context({"name": "AC", "area": "Kitchen"}) - assert result["success"] is True - assert result["result"].count("domain: climate") == 1 - assert "Kitchen" in result["result"] - assert "Office" not in result["result"] + assert result.error is False + assert result.data["result"].count("domain: climate") == 1 + assert "Kitchen" in result.data["result"] + assert "Office" not in result.data["result"] async def test_get_live_context_schema( diff --git a/tests/components/llm/test_tools.py b/tests/components/llm/test_tools.py index 66d856f6b9192..17e3bb6598c12 100644 --- a/tests/components/llm/test_tools.py +++ b/tests/components/llm/test_tools.py @@ -43,12 +43,11 @@ async def test_get_datetime_tool(hass: HomeAssistant) -> None: hass, llm.ToolInput("llm__GetDateTime", {}), llm_context ) - assert response == { - "success": True, - "result": { + assert response == llm.ToolResult( + data={ "date": "2025-09-17", "time": "13:00:00", "timezone": "UTC", "weekday": "Wednesday", - }, - } + } + ) diff --git a/tests/components/script/test_llm.py b/tests/components/script/test_llm.py index 107b5d332aeed..b81b81bdcad3e 100644 --- a/tests/components/script/test_llm.py +++ b/tests/components/script/test_llm.py @@ -84,7 +84,7 @@ async def test_script_tool_call(hass: HomeAssistant) -> None: response = await tool.async_call( hass, llm.ToolInput("script__test_script", {"beer": 1}), llm_context ) - assert response == {"success": True, "result": {"drinks": 2}} + assert response == llm.ToolResult(data={"result": {"drinks": 2}}) async def test_script_tool_name_not_started_with_digit(hass: HomeAssistant) -> None: diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 972b9adb5998c..1872a294ddb2e 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -821,10 +821,7 @@ def create_entity( result = await api.async_call_tool( llm.ToolInput(tool_name="homeassistant__GetLiveContext", tool_args={}) ) - assert result.data == { - "success": True, - "result": exposed_entities_prompt, - } + assert result.data == {"result": exposed_entities_prompt} # Fake that request is made from a specific device ID with an area llm_context.device_id = device.id @@ -1002,10 +999,7 @@ async def test_action_tool( blocking=True, return_response=True, ) - assert response.data == { - "success": True, - "result": {"drinks": 2}, - } + assert response.data == {"result": {"drinks": 2}} # Test script with no response tool_input = llm.ToolInput( @@ -1027,10 +1021,7 @@ async def test_action_tool( blocking=True, return_response=True, ) - assert response.data == { - "success": True, - "result": {}, - } + assert response.data == {"result": {}} # Test reload script with new parameters config = { From aa8bda67eeba1f123e62323fbd368cc55d8ef357 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 18 Sep 2026 19:56:26 +0200 Subject: [PATCH 06/21] Bump debugpy to 1.8.22 (#182603) --- homeassistant/components/debugpy/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json index 133bd4c11ae0d..505532142de0e 100644 --- a/homeassistant/components/debugpy/manifest.json +++ b/homeassistant/components/debugpy/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["debugpy==1.8.21"] + "requirements": ["debugpy==1.8.22"] } diff --git a/requirements_all.txt b/requirements_all.txt index f659697ed1aaa..8e32b3132ec7e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -837,7 +837,7 @@ datapoint==0.12.1 dbus-fast==5.0.22 # homeassistant.components.debugpy -debugpy==1.8.21 +debugpy==1.8.22 # homeassistant.components.decora_wifi decora-wifi==1.4 From 528d167ed60a37259f864adbe4ab4a2b3ab4c327 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 18 Sep 2026 20:18:32 +0200 Subject: [PATCH 07/21] Fix filter time moving average walking its whole window on every sample (#182600) --- homeassistant/components/filter/sensor.py | 32 +++++++++++++++++------ tests/components/filter/test_sensor.py | 24 +++++++++++++++++ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 6b381b73ac20a..c57ac16012fa0 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -696,12 +696,19 @@ def __init__( self._time_window = window_size self.last_leak: FilterState | None = None self.queue = deque[FilterState]() + # running time weighted sum of the queue, so a sample costs no pass over it + self._queue_sum: float = 0 def _leak(self, left_boundary: datetime) -> None: """Remove timeouted elements.""" while self.queue: if self.queue[0].timestamp + self._time_window <= left_boundary: self.last_leak = self.queue.popleft() + if self.queue: + self._queue_sum -= self._weighted(self.last_leak, self.queue[0]) + else: + # nothing left to weigh, so drop any rounding drift as well + self._queue_sum = 0 else: return @@ -710,22 +717,31 @@ def _filter_state(self, new_state: FilterState) -> FilterState: """Implement the Simple Moving Average filter.""" self._leak(new_state.timestamp) + if self.queue: + self._queue_sum += self._weighted(self.queue[-1], new_state) self.queue.append(copy(new_state)) - moving_sum: float = 0 + # the stretch before the oldest queued sample is covered by the value that + # left the window last, or by that oldest sample while nothing has left yet start = new_state.timestamp - self._time_window - prev_state = self.last_leak if self.last_leak is not None else self.queue[0] - for state in self.queue: - # We can cast safely here thanks to self._only_numbers = True - prev_state_value = cast(float, prev_state.state) - moving_sum += (state.timestamp - start).total_seconds() * prev_state_value - start = state.timestamp - prev_state = state + lead_state = self.last_leak if self.last_leak is not None else self.queue[0] + # We can cast safely here thanks to self._only_numbers = True + lead_value = cast(float, lead_state.state) + lead_seconds = (self.queue[0].timestamp - start).total_seconds() + moving_sum = lead_seconds * lead_value + self._queue_sum new_state.state = moving_sum / self._time_window.total_seconds() return new_state + @staticmethod + def _weighted(state: FilterState, until: FilterState) -> float: + """Return the value of a state weighted by the time until the next one.""" + # The cast is safe, the base filter only lets numbers through + return (until.timestamp - state.timestamp).total_seconds() * cast( + float, state.state + ) + @FILTERS.register(FILTER_NAME_THROTTLE) class ThrottleFilter(Filter, SensorEntity): diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index baff4bc78a648..1f3117746bfa5 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -547,6 +547,30 @@ def test_time_sma(values: list[State]) -> None: assert filtered.state == 21.5 +def test_time_sma_window() -> None: + """Test the time SMA as samples leave the window one by one, then all at once.""" + filt = TimeSMAFilter( + window_size=timedelta(minutes=2), precision=2, entity=None, type="last" + ) + start = dt_util.utcnow() + samples = [(0, 10), (60, 20), (120, 30), (180, 40), (240, 50), (600, 60), (630, 70)] + + filtered = [ + filt.filter_state( + State( + "sensor.test_monitored", + str(value), + last_updated=start + timedelta(seconds=offset), + ) + ).state + for offset, value in samples + ] + + # a value counts from its own timestamp until the next sample, and the value + # that left the window last covers the stretch before the oldest sample in it + assert filtered == [10, 10, 15, 25, 35, 50, 52.5] + + async def test_reload(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Verify we can reload filter sensors.""" hass.states.async_set("sensor.test_monitored", 12345) From 47f9262cc47baa46172f3c2fb74b45423114b345 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:38:50 +0200 Subject: [PATCH 08/21] Type UniFi Protect entities against the public device model (#182617) --- .../components/unifiprotect/camera.py | 10 ++--- homeassistant/components/unifiprotect/data.py | 4 +- .../components/unifiprotect/entity.py | 39 ++++++++----------- .../components/unifiprotect/light.py | 10 +++-- 4 files changed, 29 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 339965075f8e4..eb657ec601bdf 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -2,7 +2,7 @@ from collections.abc import Iterable import logging -from typing import cast, override +from typing import override from uiprotect.data import ( Camera as UFPCamera, @@ -266,7 +266,7 @@ def __init__( self._last_image: bytes | None = None # The base tracks the private device in hybrid (unchanged behaviour) and # the public device in public-only, so it always has a mac to key on. - super().__init__(data, cast(ProtectDeviceType, private or public)) + super().__init__(data, private or public) self._attr_unique_id = f"{self.device.mac}_{self._channel_id}" self._attr_name = get_camera_base_name(quality) # only the default (first active) quality channel is enabled by default @@ -392,11 +392,7 @@ def _async_public_camera_updated(self, obj: PublicDeviceModel | None) -> None: self._public_missing = False else: self._public_missing = True - device = ( - self._private - if self._private is not None - else cast(ProtectDeviceType, self._public) - ) + device = self._private if self._private is not None else self._public self._async_updated_event(device) @override diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index cca42c38ed620..ed0cb38225ca6 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -53,7 +53,7 @@ from .utils import async_get_devices_by_type _LOGGER = logging.getLogger(__name__) -type ProtectDeviceType = ProtectAdoptableDeviceModel | NVR +type ProtectDeviceType = ProtectAdoptableDeviceModel | NVR | PublicDeviceModel type UFPConfigEntry = ConfigEntry[ProtectData] @@ -836,7 +836,7 @@ def _async_unsubscribe_public( @callback def async_get_public_device( - self, device: ProtectDeviceType | PublicDeviceModel + self, device: ProtectDeviceType ) -> PublicDeviceModel | None: """Return the public-API object matching a device, if available.""" api = self.api diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 8ed493caed630..8dd233c2184ee 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -52,7 +52,7 @@ _LOGGER = logging.getLogger(__name__) -T = TypeVar("T", bound=ProtectAdoptableDeviceModel | NVR) +T = TypeVar("T", bound=ProtectDeviceType) class PermRequired(int, Enum): @@ -144,7 +144,7 @@ def _async_public_only_entities( entities.append( klass( data, - device=cast(ProtectDeviceType, public), + device=public, description=description, ) ) @@ -332,7 +332,7 @@ class BaseProtectEntity(Entity): def __init__( self, data: ProtectData, - device: ProtectDeviceType | PublicDeviceModel, + device: ProtectDeviceType, description: EntityDescription | None = None, ) -> None: """Initialize the entity.""" @@ -342,7 +342,7 @@ def __init__( self._ufp_has_private = False self._ufp_public_obj = device # The base keys on the mac, which both model trees carry. - self.device = cast(ProtectDeviceType, device) + self.device = device if description is None: self._attr_unique_id = self.device.mac @@ -417,7 +417,7 @@ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: self._attr_available = available @callback - def _ufp_set_target(self) -> ProtectDeviceType | PublicDeviceModel: + def _ufp_set_target(self) -> ProtectDeviceType: """Return the object a description's setter is called on. A migrated description writes through the public object it reads from, @@ -505,9 +505,7 @@ class ProtectIsOnEntity(BaseProtectEntity): entity_description: ProtectEntityDescription @override - def _async_update_device_from_protect( - self, device: ProtectAdoptableDeviceModel | NVR - ) -> None: + def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: super()._async_update_device_from_protect(device) was_on = self._attr_is_on value = self.entity_description.get_value(device, self._ufp_public_obj) @@ -521,30 +519,27 @@ class ProtectDeviceEntity(BaseProtectEntity): @callback @override def _async_set_device_info(self) -> None: - if not self._ufp_has_private: + if isinstance(device := self.device, PublicDeviceModel): # market_name/firmware/URL are private-only; the NVR link uses the # device id registered at setup. - public = self._ufp_public_obj - if TYPE_CHECKING: - assert public is not None self._attr_device_info = DeviceInfo( - name=public.display_name, - model=public.type, - model_id=public.type, + name=device.display_name, + model=device.type, + model_id=device.type, manufacturer=DEFAULT_BRAND, - connections={(dr.CONNECTION_NETWORK_MAC, public.mac)}, + connections={(dr.CONNECTION_NETWORK_MAC, device.mac)}, via_device_id=self.data.nvr_device_id, ) return self._attr_device_info = DeviceInfo( - name=self.device.display_name, + name=device.display_name, manufacturer=DEFAULT_BRAND, - model=self.device.market_name or self.device.type, - model_id=self.device.type, + model=device.market_name or device.type, + model_id=device.type, via_device_id=self.data.nvr_device_id, - sw_version=self.device.firmware_version, - connections={(dr.CONNECTION_NETWORK_MAC, self.device.mac)}, - configuration_url=self.device.protect_url, + sw_version=device.firmware_version, + connections={(dr.CONNECTION_NETWORK_MAC, device.mac)}, + configuration_url=device.protect_url, ) diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index f6dae43371422..18d916ab5f7ec 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -1,7 +1,7 @@ """Component providing Lights for UniFi Protect.""" import logging -from typing import Any, cast, override +from typing import TYPE_CHECKING, Any, cast, override from uiprotect.data import ( Light, @@ -88,7 +88,7 @@ def hass_to_unifi_brightness(value: int) -> int: class ProtectLight(ProtectDeviceEntity, LightEntity): """A Ubiquiti UniFi Protect Light Entity.""" - device: Light + device: Light | PublicLight _attr_icon = "mdi:spotlight-beam" _attr_color_mode = ColorMode.BRIGHTNESS @@ -108,7 +108,11 @@ def __init__( self._ufp_public_obj = public # unique_id and device info derive from the base device, so hybrid must # keep the private one to leave existing entities unchanged. - super().__init__(data, cast(ProtectDeviceType, private or public)) + device = private or public + if TYPE_CHECKING: + # The platform only builds a light when at least one side exists. + assert device is not None + super().__init__(data, device) @callback @override From f938a4097307a7959805a4f9e226228ca8b25732 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:48:02 +0200 Subject: [PATCH 09/21] Move cover service registration to services module (#182562) --- homeassistant/components/cover/__init__.py | 83 +--------------- homeassistant/components/cover/const.py | 10 +- homeassistant/components/cover/services.py | 108 +++++++++++++++++++++ 3 files changed, 121 insertions(+), 80 deletions(-) create mode 100644 homeassistant/components/cover/services.py diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 0dbd36396aabf..23625cfa813eb 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -6,11 +6,10 @@ import logging from typing import Any, final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, @@ -29,7 +28,6 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util.hass_dict import HassKey from .condition import make_cover_is_closed_condition, make_cover_is_open_condition from .const import ( @@ -39,6 +37,7 @@ ATTR_POSITION, ATTR_SPEED, ATTR_TILT_POSITION, + DATA_COMPONENT, DEVICE_CLASSES_SCHEMA, DOMAIN, INTENT_CLOSE_COVER, @@ -49,11 +48,11 @@ CoverEntityStateAttribute, CoverState, ) +from .services import async_setup_services from .trigger import make_cover_closed_trigger, make_cover_opened_trigger _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[CoverEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -109,81 +108,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_setup(config) - component.async_register_entity_service( - SERVICE_OPEN_COVER, - {probatio.Optional(ATTR_SPEED): cv.string}, - "async_handle_open_cover", - [CoverEntityFeature.OPEN], - ) - - component.async_register_entity_service( - SERVICE_CLOSE_COVER, - {probatio.Optional(ATTR_SPEED): cv.string}, - "async_handle_close_cover", - [CoverEntityFeature.CLOSE], - ) - - component.async_register_entity_service( - SERVICE_SET_COVER_POSITION, - { - probatio.Required(ATTR_POSITION): probatio.All( - probatio.Coerce(int), probatio.Range(min=0, max=100) - ), - probatio.Optional(ATTR_SPEED): cv.string, - }, - "async_handle_set_cover_position", - [CoverEntityFeature.SET_POSITION], - ) - - component.async_register_entity_service( - SERVICE_STOP_COVER, None, "async_stop_cover", [CoverEntityFeature.STOP] - ) - - component.async_register_entity_service( - SERVICE_TOGGLE, - None, - "async_toggle", - [CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE], - ) - - component.async_register_entity_service( - SERVICE_OPEN_COVER_TILT, - None, - "async_open_cover_tilt", - [CoverEntityFeature.OPEN_TILT], - ) - - component.async_register_entity_service( - SERVICE_CLOSE_COVER_TILT, - None, - "async_close_cover_tilt", - [CoverEntityFeature.CLOSE_TILT], - ) - - component.async_register_entity_service( - SERVICE_STOP_COVER_TILT, - None, - "async_stop_cover_tilt", - [CoverEntityFeature.STOP_TILT], - ) - - component.async_register_entity_service( - SERVICE_SET_COVER_TILT_POSITION, - { - probatio.Required(ATTR_TILT_POSITION): probatio.All( - probatio.Coerce(int), probatio.Range(min=0, max=100) - ) - }, - "async_set_cover_tilt_position", - [CoverEntityFeature.SET_TILT_POSITION], - ) - - component.async_register_entity_service( - SERVICE_TOGGLE_COVER_TILT, - None, - "async_toggle_tilt", - [CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT], - ) + async_setup_services(hass) return True diff --git a/homeassistant/components/cover/const.py b/homeassistant/components/cover/const.py index aadf0b13985d4..b33a6caffa79f 100644 --- a/homeassistant/components/cover/const.py +++ b/homeassistant/components/cover/const.py @@ -1,11 +1,19 @@ """Constants for cover entity platform.""" from enum import IntFlag, StrEnum -from typing import Final +from typing import TYPE_CHECKING, Final import probatio +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import CoverEntity + DOMAIN: Final = "cover" +DATA_COMPONENT: HassKey[EntityComponent[CoverEntity]] = HassKey(DOMAIN) ATTR_CURRENT_POSITION = "current_position" ATTR_CURRENT_TILT_POSITION = "current_tilt_position" diff --git a/homeassistant/components/cover/services.py b/homeassistant/components/cover/services.py new file mode 100644 index 0000000000000..74cee492945d8 --- /dev/null +++ b/homeassistant/components/cover/services.py @@ -0,0 +1,108 @@ +"""Services for the Cover integration.""" + +import probatio + +from homeassistant.const import ( + SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, + SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, + SERVICE_STOP_COVER_TILT, + SERVICE_TOGGLE, + SERVICE_TOGGLE_COVER_TILT, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv + +from .const import ( + ATTR_POSITION, + ATTR_SPEED, + ATTR_TILT_POSITION, + DATA_COMPONENT, + CoverEntityFeature, +) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the cover services.""" + component = hass.data[DATA_COMPONENT] + + component.async_register_entity_service( + SERVICE_OPEN_COVER, + {probatio.Optional(ATTR_SPEED): cv.string}, + "async_handle_open_cover", + [CoverEntityFeature.OPEN], + ) + + component.async_register_entity_service( + SERVICE_CLOSE_COVER, + {probatio.Optional(ATTR_SPEED): cv.string}, + "async_handle_close_cover", + [CoverEntityFeature.CLOSE], + ) + + component.async_register_entity_service( + SERVICE_SET_COVER_POSITION, + { + probatio.Required(ATTR_POSITION): probatio.All( + probatio.Coerce(int), probatio.Range(min=0, max=100) + ), + probatio.Optional(ATTR_SPEED): cv.string, + }, + "async_handle_set_cover_position", + [CoverEntityFeature.SET_POSITION], + ) + + component.async_register_entity_service( + SERVICE_STOP_COVER, None, "async_stop_cover", [CoverEntityFeature.STOP] + ) + + component.async_register_entity_service( + SERVICE_TOGGLE, + None, + "async_toggle", + [CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE], + ) + + component.async_register_entity_service( + SERVICE_OPEN_COVER_TILT, + None, + "async_open_cover_tilt", + [CoverEntityFeature.OPEN_TILT], + ) + + component.async_register_entity_service( + SERVICE_CLOSE_COVER_TILT, + None, + "async_close_cover_tilt", + [CoverEntityFeature.CLOSE_TILT], + ) + + component.async_register_entity_service( + SERVICE_STOP_COVER_TILT, + None, + "async_stop_cover_tilt", + [CoverEntityFeature.STOP_TILT], + ) + + component.async_register_entity_service( + SERVICE_SET_COVER_TILT_POSITION, + { + probatio.Required(ATTR_TILT_POSITION): probatio.All( + probatio.Coerce(int), probatio.Range(min=0, max=100) + ) + }, + "async_set_cover_tilt_position", + [CoverEntityFeature.SET_TILT_POSITION], + ) + + component.async_register_entity_service( + SERVICE_TOGGLE_COVER_TILT, + None, + "async_toggle_tilt", + [CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT], + ) From d3e91e2c1013983ddca9be797ff5a9195d543781 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:48:05 +0200 Subject: [PATCH 10/21] Move tts service registration to services module (#182561) --- homeassistant/components/tts/__init__.py | 35 +++-------------- homeassistant/components/tts/const.py | 3 ++ homeassistant/components/tts/notify.py | 2 +- homeassistant/components/tts/services.py | 49 ++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 31 deletions(-) create mode 100644 homeassistant/components/tts/services.py diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 5e556e62c85b1..71adc8a987522 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -35,20 +35,19 @@ HassJob, HassJobType, HomeAssistant, - ServiceCall, callback, ) from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import get_url from homeassistant.helpers.typing import UNDEFINED, ConfigType from homeassistant.util import language as language_util, ulid as ulid_util -from .const import ( +from .const import ( # noqa: F401 ATTR_CACHE, ATTR_LANGUAGE, + ATTR_MEDIA_PLAYER_ENTITY_ID, ATTR_MESSAGE, ATTR_OPTIONS, CONF_CACHE, @@ -61,6 +60,7 @@ DEFAULT_TIME_MEMORY, DOMAIN, MEDIA_SOURCE_STREAM_PATH, + SERVICE_CLEAR_CACHE, TtsAudioType, ) from .entity import TextToSpeechEntity, TTSAudioRequest, TTSAudioResponse @@ -68,6 +68,7 @@ from .legacy import PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, Provider, async_setup_legacy from .media_source import generate_media_source_id, parse_media_source_id from .models import Voice +from .services import async_setup_services __all__ = [ "ATTR_AUDIO_OUTPUT", @@ -101,7 +102,6 @@ ATTR_PREFERRED_SAMPLE_CHANNELS = "preferred_sample_channels" ATTR_PREFERRED_SAMPLE_BYTES = "preferred_sample_bytes" ATTR_PREFERRED_BITRATE = "preferred_bitrate" -ATTR_MEDIA_PLAYER_ENTITY_ID = "media_player_entity_id" ATTR_VOICE = "voice" _DEFAULT_FORMAT = "mp3" @@ -115,8 +115,6 @@ CONF_LANG = "language" -SERVICE_CLEAR_CACHE = "clear_cache" - _RE_LEGACY_VOICE_FILE = re.compile( r"([a-f0-9]{40})_([^_]+)_([^_]+)_([a-z_]+)\.[a-z0-9]{3,4}" ) @@ -125,8 +123,6 @@ ) KEY_PATTERN = "{0}_{1}_{2}_{3}" -SCHEMA_SERVICE_CLEAR_CACHE = probatio.Schema({}) - FFMPEG_CHUNK_SIZE: Final[int] = 4096 @@ -445,28 +441,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: platform_setups = await async_setup_legacy(hass, config) - component.async_register_entity_service( - "speak", - { - probatio.Required(ATTR_MEDIA_PLAYER_ENTITY_ID): cv.comp_entity_ids, - probatio.Required(ATTR_MESSAGE): cv.string, - probatio.Optional(ATTR_CACHE, default=DEFAULT_CACHE): cv.boolean, - probatio.Optional(ATTR_LANGUAGE): cv.string, - probatio.Optional(ATTR_OPTIONS): dict, - }, - "async_speak", - ) - - async def async_clear_cache_handle(service: ServiceCall) -> None: - """Handle clear cache service call.""" - await tts.async_clear_cache() - - hass.services.async_register( - DOMAIN, - SERVICE_CLEAR_CACHE, - async_clear_cache_handle, - schema=SCHEMA_SERVICE_CLEAR_CACHE, - ) + async_setup_services(hass) for setup in platform_setups: # Tasks are created as tracked tasks to ensure startup diff --git a/homeassistant/components/tts/const.py b/homeassistant/components/tts/const.py index 140d493b2b311..ecede41fcb508 100644 --- a/homeassistant/components/tts/const.py +++ b/homeassistant/components/tts/const.py @@ -11,6 +11,7 @@ ATTR_CACHE = "cache" ATTR_LANGUAGE = "language" +ATTR_MEDIA_PLAYER_ENTITY_ID = "media_player_entity_id" ATTR_MESSAGE = "message" ATTR_OPTIONS = "options" @@ -30,4 +31,6 @@ MEDIA_SOURCE_STREAM_PATH = "-stream-" +SERVICE_CLEAR_CACHE = "clear_cache" + type TtsAudioType = tuple[str | None, bytes | None] diff --git a/homeassistant/components/tts/notify.py b/homeassistant/components/tts/notify.py index de17ab9583a4e..5457e1c262712 100644 --- a/homeassistant/components/tts/notify.py +++ b/homeassistant/components/tts/notify.py @@ -14,7 +14,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ATTR_LANGUAGE, ATTR_MEDIA_PLAYER_ENTITY_ID, ATTR_MESSAGE, DOMAIN +from .const import ATTR_LANGUAGE, ATTR_MEDIA_PLAYER_ENTITY_ID, ATTR_MESSAGE, DOMAIN CONF_MEDIA_PLAYER = "media_player" CONF_TTS_SERVICE = "tts_service" diff --git a/homeassistant/components/tts/services.py b/homeassistant/components/tts/services.py new file mode 100644 index 0000000000000..9a824f1db631e --- /dev/null +++ b/homeassistant/components/tts/services.py @@ -0,0 +1,49 @@ +"""Services for the Text-to-speech integration.""" + +import probatio + +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv + +from .const import ( + ATTR_CACHE, + ATTR_LANGUAGE, + ATTR_MEDIA_PLAYER_ENTITY_ID, + ATTR_MESSAGE, + ATTR_OPTIONS, + DATA_COMPONENT, + DATA_TTS_MANAGER, + DEFAULT_CACHE, + DOMAIN, + SERVICE_CLEAR_CACHE, +) + +SCHEMA_SERVICE_CLEAR_CACHE = probatio.Schema({}) + + +async def _async_clear_cache_handle(service: ServiceCall) -> None: + """Handle clear cache service call.""" + await service.hass.data[DATA_TTS_MANAGER].async_clear_cache() + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the Text-to-speech services.""" + hass.data[DATA_COMPONENT].async_register_entity_service( + "speak", + { + probatio.Required(ATTR_MEDIA_PLAYER_ENTITY_ID): cv.comp_entity_ids, + probatio.Required(ATTR_MESSAGE): cv.string, + probatio.Optional(ATTR_CACHE, default=DEFAULT_CACHE): cv.boolean, + probatio.Optional(ATTR_LANGUAGE): cv.string, + probatio.Optional(ATTR_OPTIONS): dict, + }, + "async_speak", + ) + + hass.services.async_register( + DOMAIN, + SERVICE_CLEAR_CACHE, + _async_clear_cache_handle, + schema=SCHEMA_SERVICE_CLEAR_CACHE, + ) From 3bf54699b21e1bd3919cf155e262bd41d3a20bbe Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:48:26 +0200 Subject: [PATCH 11/21] Move button service registration to services module (#182563) --- homeassistant/components/button/__init__.py | 10 +++------- homeassistant/components/button/const.py | 10 +++++++++- homeassistant/components/button/services.py | 15 +++++++++++++++ 3 files changed, 27 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/button/services.py diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py index 946f6e15d2d44..fb9bdc44be991 100644 --- a/homeassistant/components/button/__init__.py +++ b/homeassistant/components/button/__init__.py @@ -15,18 +15,18 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 + DATA_COMPONENT, DEVICE_CLASSES_SCHEMA, DOMAIN, SERVICE_PRESS, ButtonDeviceClass, ) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[ButtonEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -45,11 +45,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await component.async_setup(config) - component.async_register_entity_service( - SERVICE_PRESS, - None, - "_async_press_action", - ) + async_setup_services(hass) return True diff --git a/homeassistant/components/button/const.py b/homeassistant/components/button/const.py index 25d40bacb5233..05fee14e77283 100644 --- a/homeassistant/components/button/const.py +++ b/homeassistant/components/button/const.py @@ -1,11 +1,19 @@ """Provides the constants needed for the component.""" from enum import StrEnum -from typing import Final +from typing import TYPE_CHECKING, Final import probatio +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import ButtonEntity + DOMAIN: Final = "button" +DATA_COMPONENT: HassKey[EntityComponent[ButtonEntity]] = HassKey(DOMAIN) SERVICE_PRESS = "press" diff --git a/homeassistant/components/button/services.py b/homeassistant/components/button/services.py new file mode 100644 index 0000000000000..4ef5de7745b69 --- /dev/null +++ b/homeassistant/components/button/services.py @@ -0,0 +1,15 @@ +"""Services for the Button integration.""" + +from homeassistant.core import HomeAssistant, callback + +from .const import DATA_COMPONENT, SERVICE_PRESS + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the button services.""" + hass.data[DATA_COMPONENT].async_register_entity_service( + SERVICE_PRESS, + None, + "_async_press_action", + ) From 24742fe2e0b1e723dd2d0cd621ae3b42c6fcaaa8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:48:38 +0200 Subject: [PATCH 12/21] Move scene service registration to services module (#182564) --- homeassistant/components/scene/__init__.py | 23 +++++++++------------- homeassistant/components/scene/const.py | 10 +++++++++- homeassistant/components/scene/services.py | 23 ++++++++++++++++++++++ 3 files changed, 41 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/scene/services.py diff --git a/homeassistant/components/scene/__init__.py b/homeassistant/components/scene/__init__.py index be8b4e0f15285..9f28a650ff542 100644 --- a/homeassistant/components/scene/__init__.py +++ b/homeassistant/components/scene/__init__.py @@ -7,20 +7,23 @@ import probatio -from homeassistant.components.light import ATTR_TRANSITION +from homeassistant.components.light import ATTR_TRANSITION # noqa: F401 from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PLATFORM, SERVICE_TURN_ON, STATE_UNAVAILABLE +from homeassistant.const import ( # noqa: F401 + CONF_PLATFORM, + SERVICE_TURN_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe -from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN +from .const import DATA_COMPONENT, DOMAIN +from .services import async_setup_services -DATA_COMPONENT: HassKey[EntityComponent[BaseScene]] = HassKey(DOMAIN) STATES: Final = "states" @@ -76,15 +79,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ), eager_start=True, ) - component.async_register_entity_service( - SERVICE_TURN_ON, - { - ATTR_TRANSITION: probatio.All( - probatio.Coerce(float), probatio.Clamp(min=0, max=6553) - ) - }, - "_async_activate", - ) + async_setup_services(hass) return True diff --git a/homeassistant/components/scene/const.py b/homeassistant/components/scene/const.py index f2c09d5539235..7de13d6e62673 100644 --- a/homeassistant/components/scene/const.py +++ b/homeassistant/components/scene/const.py @@ -1,5 +1,13 @@ """Constants for the scene integration.""" -from typing import Final +from typing import TYPE_CHECKING, Final + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import BaseScene DOMAIN: Final = "scene" +DATA_COMPONENT: HassKey[EntityComponent[BaseScene]] = HassKey(DOMAIN) diff --git a/homeassistant/components/scene/services.py b/homeassistant/components/scene/services.py new file mode 100644 index 0000000000000..adeb9fd0c477f --- /dev/null +++ b/homeassistant/components/scene/services.py @@ -0,0 +1,23 @@ +"""Services for the Scene integration.""" + +import probatio + +from homeassistant.components.light import ATTR_TRANSITION +from homeassistant.const import SERVICE_TURN_ON +from homeassistant.core import HomeAssistant, callback + +from .const import DATA_COMPONENT + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the scene services.""" + hass.data[DATA_COMPONENT].async_register_entity_service( + SERVICE_TURN_ON, + { + ATTR_TRANSITION: probatio.All( + probatio.Coerce(float), probatio.Clamp(min=0, max=6553) + ) + }, + "_async_activate", + ) From 8620c857795ee7018224d489495a781fce95bb3e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:48:59 +0200 Subject: [PATCH 13/21] Move lawn mower service registration to services module (#182566) --- .../components/lawn_mower/__init__.py | 22 +++--------- homeassistant/components/lawn_mower/const.py | 10 +++++- .../components/lawn_mower/services.py | 34 +++++++++++++++++++ 3 files changed, 47 insertions(+), 19 deletions(-) create mode 100644 homeassistant/components/lawn_mower/services.py diff --git a/homeassistant/components/lawn_mower/__init__.py b/homeassistant/components/lawn_mower/__init__.py index fc6099166ed90..9e25c5ff062bd 100644 --- a/homeassistant/components/lawn_mower/__init__.py +++ b/homeassistant/components/lawn_mower/__init__.py @@ -12,9 +12,9 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util.hass_dict import HassKey -from .const import ( +from .const import ( # noqa: F401 + DATA_COMPONENT, DOMAIN, SERVICE_DOCK, SERVICE_PAUSE, @@ -23,10 +23,10 @@ LawnMowerActivity, LawnMowerEntityFeature, ) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[LawnMowerEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -40,21 +40,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await component.async_setup(config) - component.async_register_entity_service( - SERVICE_START_MOWING, - None, - "async_start_mowing", - [LawnMowerEntityFeature.START_MOWING], - ) - component.async_register_entity_service( - SERVICE_PAUSE, None, "async_pause", [LawnMowerEntityFeature.PAUSE] - ) - component.async_register_entity_service( - SERVICE_DOCK, None, "async_dock", [LawnMowerEntityFeature.DOCK] - ) - component.async_register_entity_service( - SERVICE_STOP, None, "async_stop", [LawnMowerEntityFeature.STOP] - ) + async_setup_services(hass) return True diff --git a/homeassistant/components/lawn_mower/const.py b/homeassistant/components/lawn_mower/const.py index 00d5502c97fea..a8fb9e9818226 100644 --- a/homeassistant/components/lawn_mower/const.py +++ b/homeassistant/components/lawn_mower/const.py @@ -1,9 +1,17 @@ """Constants for the lawn mower integration.""" from enum import IntFlag, StrEnum -from typing import Final +from typing import TYPE_CHECKING, Final + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import LawnMowerEntity DOMAIN: Final = "lawn_mower" +DATA_COMPONENT: HassKey[EntityComponent[LawnMowerEntity]] = HassKey(DOMAIN) class LawnMowerActivity(StrEnum): diff --git a/homeassistant/components/lawn_mower/services.py b/homeassistant/components/lawn_mower/services.py new file mode 100644 index 0000000000000..dadb907d05ba9 --- /dev/null +++ b/homeassistant/components/lawn_mower/services.py @@ -0,0 +1,34 @@ +"""Services for the Lawn mower integration.""" + +from homeassistant.core import HomeAssistant, callback + +from .const import ( + DATA_COMPONENT, + SERVICE_DOCK, + SERVICE_PAUSE, + SERVICE_START_MOWING, + SERVICE_STOP, + LawnMowerEntityFeature, +) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the lawn mower services.""" + component = hass.data[DATA_COMPONENT] + + component.async_register_entity_service( + SERVICE_START_MOWING, + None, + "async_start_mowing", + [LawnMowerEntityFeature.START_MOWING], + ) + component.async_register_entity_service( + SERVICE_PAUSE, None, "async_pause", [LawnMowerEntityFeature.PAUSE] + ) + component.async_register_entity_service( + SERVICE_DOCK, None, "async_dock", [LawnMowerEntityFeature.DOCK] + ) + component.async_register_entity_service( + SERVICE_STOP, None, "async_stop", [LawnMowerEntityFeature.STOP] + ) From 6ece6743c6b5e5197527ddffc3eb8f1325b11fda Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:49:03 +0200 Subject: [PATCH 14/21] Move switch service registration to services module (#182565) --- homeassistant/components/switch/__init__.py | 16 +++++++++------- homeassistant/components/switch/const.py | 10 +++++++++- homeassistant/components/switch/services.py | 16 ++++++++++++++++ 3 files changed, 34 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/switch/services.py diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index 54ab6b2f577ed..62c7a44fb2c37 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -7,7 +7,7 @@ from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, @@ -18,13 +18,17 @@ from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util.hass_dict import HassKey -from .const import DEVICE_CLASSES_SCHEMA, DOMAIN, SwitchDeviceClass # noqa: F401 +from .const import ( # noqa: F401 + DATA_COMPONENT, + DEVICE_CLASSES_SCHEMA, + DOMAIN, + SwitchDeviceClass, +) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[SwitchEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -53,9 +57,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await component.async_setup(config) - component.async_register_entity_service(SERVICE_TURN_OFF, None, "async_turn_off") - component.async_register_entity_service(SERVICE_TURN_ON, None, "async_turn_on") - component.async_register_entity_service(SERVICE_TOGGLE, None, "async_toggle") + async_setup_services(hass) return True diff --git a/homeassistant/components/switch/const.py b/homeassistant/components/switch/const.py index 4410669bc952b..222caf707eb34 100644 --- a/homeassistant/components/switch/const.py +++ b/homeassistant/components/switch/const.py @@ -1,11 +1,19 @@ """Constants for the Switch integration.""" from enum import StrEnum -from typing import Final +from typing import TYPE_CHECKING, Final import probatio +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import SwitchEntity + DOMAIN: Final = "switch" +DATA_COMPONENT: HassKey[EntityComponent[SwitchEntity]] = HassKey(DOMAIN) class SwitchDeviceClass(StrEnum): diff --git a/homeassistant/components/switch/services.py b/homeassistant/components/switch/services.py new file mode 100644 index 0000000000000..12a4b853f90a8 --- /dev/null +++ b/homeassistant/components/switch/services.py @@ -0,0 +1,16 @@ +"""Services for the Switch integration.""" + +from homeassistant.const import SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant, callback + +from .const import DATA_COMPONENT + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the switch services.""" + component = hass.data[DATA_COMPONENT] + + component.async_register_entity_service(SERVICE_TURN_OFF, None, "async_turn_off") + component.async_register_entity_service(SERVICE_TURN_ON, None, "async_turn_on") + component.async_register_entity_service(SERVICE_TOGGLE, None, "async_toggle") From 9958b3680af21ee7252fc9dc8760cfbeedcdebff Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 18 Sep 2026 20:53:50 +0200 Subject: [PATCH 15/21] Use mock client in Airly tests (#182485) --- tests/components/airly/__init__.py | 40 +--- tests/components/airly/conftest.py | 79 ++++++++ tests/components/airly/test_config_flow.py | 125 ++++++------ tests/components/airly/test_diagnostics.py | 12 +- tests/components/airly/test_init.py | 190 ++++++++++--------- tests/components/airly/test_sensor.py | 50 +++-- tests/components/airly/test_system_health.py | 17 +- 7 files changed, 289 insertions(+), 224 deletions(-) create mode 100644 tests/components/airly/conftest.py diff --git a/tests/components/airly/__init__.py b/tests/components/airly/__init__.py index 05d742603da6b..95531b31c47b2 100644 --- a/tests/components/airly/__init__.py +++ b/tests/components/airly/__init__.py @@ -1,44 +1,16 @@ """Tests for Airly.""" -from homeassistant.components.airly.const import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_load_fixture -from tests.test_util.aiohttp import AiohttpClientMocker - -API_NEAREST_URL = "https://airapi.airly.eu/v2/measurements/nearest?lat=12.300000&lng=45.600000&maxDistanceKM=5.000000" -API_POINT_URL = ( - "https://airapi.airly.eu/v2/measurements/point?lat=12.300000&lng=45.600000" -) -HEADERS = { - "X-RateLimit-Limit-day": "100", - "X-RateLimit-Remaining-day": "42", -} +from tests.common import MockConfigEntry async def init_integration( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> MockConfigEntry: + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: """Set up the Airly integration in Home Assistant.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="Home", - entry_id="3bd2acb0e4f0476d40865546d0d91921", - unique_id="12.3-45.6", - data={ - "api_key": "foo", - "latitude": 12.3, - "longitude": 45.6, - }, - ) + config_entry.add_to_hass(hass) - aioclient_mock.get( - API_POINT_URL, - text=await async_load_fixture(hass, "valid_station.json", DOMAIN), - headers=HEADERS, - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - - return entry diff --git a/tests/components/airly/conftest.py b/tests/components/airly/conftest.py new file mode 100644 index 0000000000000..d9fc8c93e61b9 --- /dev/null +++ b/tests/components/airly/conftest.py @@ -0,0 +1,79 @@ +"""Fixtures for the Airly integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from airly.measurements import Measurement +import pytest + +from homeassistant.components.airly.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Home", + entry_id="3bd2acb0e4f0476d40865546d0d91921", + unique_id="12.3-45.6", + data={ + CONF_API_KEY: "foo", + CONF_LATITUDE: 12.3, + CONF_LONGITUDE: 45.6, + }, + ) + + +def _measurements(filename: str) -> Measurement: + """Build Airly measurements from a fixture.""" + data = load_json_object_fixture(filename, DOMAIN)["current"] + return Measurement(data) + + +@pytest.fixture +def mock_airly_measurements() -> Measurement: + """Return the default mocked Airly measurements.""" + return _measurements("valid_station.json") + + +@pytest.fixture +def mock_airly_no_station_measurements() -> Measurement: + """Return the mocked Airly measurements for an area without sensors.""" + return _measurements("no_station.json") + + +@pytest.fixture +def mock_airly() -> Generator[MagicMock]: + """Mock the Airly client class.""" + with ( + patch( + "homeassistant.components.airly.coordinator.Airly", autospec=True + ) as mock_airly, + patch("homeassistant.components.airly.config_flow.Airly", new=mock_airly), + ): + yield mock_airly + + +@pytest.fixture +def mock_airly_client( + mock_airly: MagicMock, + mock_airly_measurements: Measurement, +) -> MagicMock: + """Mock an Airly client instance.""" + client = mock_airly.return_value + + for measurements in ( + client.create_measurements_session_point.return_value, + client.create_measurements_session_nearest.return_value, + ): + measurements.current = mock_airly_measurements + measurements.update = AsyncMock() + + client.requests_remaining = 42 + client.requests_per_day = 100 + + return client diff --git a/tests/components/airly/test_config_flow.py b/tests/components/airly/test_config_flow.py index d9ba6418401a1..f5b190b310ab7 100644 --- a/tests/components/airly/test_config_flow.py +++ b/tests/components/airly/test_config_flow.py @@ -2,10 +2,11 @@ from collections.abc import Generator from http import HTTPStatus -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch from aiohttp import ClientConnectorError from airly.exceptions import AirlyError +from airly.measurements import Measurement import pytest from homeassistant.components.airly.const import CONF_USE_NEAREST, DEFAULT_NAME, DOMAIN @@ -14,10 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import API_NEAREST_URL, API_POINT_URL - -from tests.common import MockConfigEntry, async_load_fixture -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import MockConfigEntry CONFIG = { CONF_API_KEY: "foo", @@ -36,14 +34,15 @@ def mock_setup_entry() -> Generator[AsyncMock]: async def test_invalid_api_key( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_airly_client: MagicMock, ) -> None: """Test that errors are shown when API key is invalid.""" - aioclient_mock.get( - API_POINT_URL, - exc=AirlyError( - HTTPStatus.UNAUTHORIZED, {"message": "Invalid authentication credentials"} - ), + point_measurements = ( + mock_airly_client.create_measurements_session_point.return_value + ) + point_measurements.update.side_effect = AirlyError( + HTTPStatus.UNAUTHORIZED, {"message": "Invalid authentication credentials"} ) result = await hass.config_entries.flow.async_init( @@ -59,10 +58,7 @@ async def test_invalid_api_key( assert result["errors"] == {"base": "invalid_api_key"} - aioclient_mock.clear_requests() - aioclient_mock.get( - API_POINT_URL, text=await async_load_fixture(hass, "valid_station.json", DOMAIN) - ) + point_measurements.update.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=CONFIG @@ -77,16 +73,17 @@ async def test_invalid_api_key( async def test_invalid_location( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_airly_client: MagicMock, + mock_airly_measurements: Measurement, + mock_airly_no_station_measurements: Measurement, ) -> None: """Test that errors are shown when location is invalid.""" - aioclient_mock.get( - API_POINT_URL, text=await async_load_fixture(hass, "no_station.json", DOMAIN) + mock_airly_client.create_measurements_session_point.return_value.current = ( + mock_airly_no_station_measurements ) - - aioclient_mock.get( - API_NEAREST_URL, - exc=AirlyError(HTTPStatus.NOT_FOUND, {"message": "Installation was not found"}), + mock_airly_client.create_measurements_session_nearest.return_value.update.side_effect = AirlyError( + HTTPStatus.NOT_FOUND, {"message": "Installation was not found"} ) result = await hass.config_entries.flow.async_init( @@ -102,9 +99,8 @@ async def test_invalid_location( assert result["errors"] == {"base": "wrong_location"} - aioclient_mock.clear_requests() - aioclient_mock.get( - API_POINT_URL, text=await async_load_fixture(hass, "valid_station.json", DOMAIN) + mock_airly_client.create_measurements_session_point.return_value.current = ( + mock_airly_measurements ) result = await hass.config_entries.flow.async_configure( @@ -120,16 +116,16 @@ async def test_invalid_location( async def test_invalid_location_for_point_and_nearest( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_airly_client: MagicMock, + mock_airly_no_station_measurements: Measurement, ) -> None: """Test an abort when the location is wrong for the point and nearest methods.""" - - aioclient_mock.get( - API_POINT_URL, text=await async_load_fixture(hass, "no_station.json", DOMAIN) + mock_airly_client.create_measurements_session_point.return_value.current = ( + mock_airly_no_station_measurements ) - - aioclient_mock.get( - API_NEAREST_URL, text=await async_load_fixture(hass, "no_station.json", DOMAIN) + mock_airly_client.create_measurements_session_nearest.return_value.current = ( + mock_airly_no_station_measurements ) result = await hass.config_entries.flow.async_init( @@ -148,12 +144,10 @@ async def test_invalid_location_for_point_and_nearest( async def test_duplicate_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_airly_client: MagicMock, ) -> None: """Test that errors are shown when duplicates are added.""" - aioclient_mock.get( - API_POINT_URL, text=await async_load_fixture(hass, "valid_station.json", DOMAIN) - ) MockConfigEntry(domain=DOMAIN, unique_id="12.3-45.6", data=CONFIG).add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -172,13 +166,10 @@ async def test_duplicate_error( async def test_create_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_airly_client: MagicMock, ) -> None: """Test that the user step works.""" - aioclient_mock.get( - API_POINT_URL, text=await async_load_fixture(hass, "valid_station.json", DOMAIN) - ) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -199,17 +190,13 @@ async def test_create_entry( async def test_create_entry_with_nearest_method( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_airly_client: MagicMock, + mock_airly_no_station_measurements: Measurement, ) -> None: """Test that the user step works with nearest method.""" - - aioclient_mock.get( - API_POINT_URL, text=await async_load_fixture(hass, "no_station.json", DOMAIN) - ) - - aioclient_mock.get( - API_NEAREST_URL, - text=await async_load_fixture(hass, "valid_station.json", DOMAIN), + mock_airly_client.create_measurements_session_point.return_value.current = ( + mock_airly_no_station_measurements ) result = await hass.config_entries.flow.async_init( @@ -240,7 +227,7 @@ async def test_create_entry_with_nearest_method( ) async def test_cannot_connect( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + mock_airly_client: MagicMock, exception: Exception, error: str, ) -> None: @@ -252,19 +239,20 @@ async def test_cannot_connect( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - with patch("airly.measurements.MeasurementsSession.update", side_effect=exception): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=CONFIG - ) + point_measurements = ( + mock_airly_client.create_measurements_session_point.return_value + ) + point_measurements.update.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG + ) assert result["errors"] == {"base": error} assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - aioclient_mock.clear_requests() - aioclient_mock.get( - API_POINT_URL, text=await async_load_fixture(hass, "valid_station.json", DOMAIN) - ) + point_measurements.update.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=CONFIG @@ -285,7 +273,7 @@ async def test_cannot_connect( ) async def test_unknown_error( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + mock_airly_client: MagicMock, exception: Exception, error: str, ) -> None: @@ -297,19 +285,20 @@ async def test_unknown_error( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - with patch("airly.measurements.MeasurementsSession.update", side_effect=exception): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=CONFIG - ) + point_measurements = ( + mock_airly_client.create_measurements_session_point.return_value + ) + point_measurements.update.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG + ) assert result["errors"] == {"base": error} assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - aioclient_mock.clear_requests() - aioclient_mock.get( - API_POINT_URL, text=await async_load_fixture(hass, "valid_station.json", DOMAIN) - ) + point_measurements.update.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=CONFIG diff --git a/tests/components/airly/test_diagnostics.py b/tests/components/airly/test_diagnostics.py index 13656f90a68f9..b0527bf3176dd 100644 --- a/tests/components/airly/test_diagnostics.py +++ b/tests/components/airly/test_diagnostics.py @@ -1,5 +1,6 @@ """Test Airly diagnostics.""" +import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import props @@ -7,20 +8,23 @@ from . import init_integration +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry -from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator +@pytest.mark.usefixtures("mock_airly_client") async def test_entry_diagnostics( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, ) -> None: """Test config entry diagnostics.""" - entry = await init_integration(hass, aioclient_mock) + await init_integration(hass, mock_config_entry) - result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) assert result == snapshot(exclude=props("created_at", "modified_at")) diff --git a/tests/components/airly/test_init.py b/tests/components/airly/test_init.py index 8058f751c787a..2c675d8073982 100644 --- a/tests/components/airly/test_init.py +++ b/tests/components/airly/test_init.py @@ -1,29 +1,37 @@ """Test init of Airly integration.""" from typing import Any +from unittest.mock import MagicMock +from airly.measurements import Measurement from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_DOMAIN -from homeassistant.components.airly.const import DOMAIN +from homeassistant.components.airly.const import CONF_USE_NEAREST, DOMAIN from homeassistant.components.airly.coordinator import set_update_interval from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import API_POINT_URL, init_integration +from . import init_integration -from tests.common import MockConfigEntry, async_fire_time_changed, async_load_fixture -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.mark.usefixtures("mock_airly_client") async def test_async_setup_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, ) -> None: """Test a successful setup entry.""" - await init_integration(hass, aioclient_mock) + await init_integration(hass, mock_config_entry) state = hass.states.get("sensor.home_pm2_5") assert state is not None @@ -31,108 +39,108 @@ async def test_async_setup_entry( assert state.state == "4.37" -async def test_config_not_ready( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +async def test_async_setup_entry_with_nearest( + hass: HomeAssistant, + mock_airly_client: MagicMock, ) -> None: - """Test for setup failure if connection to Airly is missing.""" + """Test a successful setup entry with nearest station.""" entry = MockConfigEntry( domain=DOMAIN, title="Home", unique_id="12.3-45.6", data={ - "api_key": "foo", - "latitude": 12.3, - "longitude": 45.6, - "use_nearest": True, + CONF_API_KEY: "foo", + CONF_LATITUDE: 12.3, + CONF_LONGITUDE: 45.6, + CONF_USE_NEAREST: True, }, ) - aioclient_mock.get(API_POINT_URL, exc=ConnectionError()) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY + await init_integration(hass, entry) + assert entry.state is ConfigEntryState.LOADED + mock_airly_client.create_measurements_session_nearest.assert_called_once_with( + 12.3, 45.6, max_distance_km=5 + ) + mock_airly_client.create_measurements_session_point.assert_not_called() -async def test_config_without_unique_id( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + state = hass.states.get("sensor.home_pm2_5") + assert state is not None + assert state.state == "4.37" + + +async def test_config_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_airly_client: MagicMock, ) -> None: + """Test for setup failure if connection to Airly is missing.""" + mock_airly_client.create_measurements_session_point.return_value.update.side_effect = ConnectionError() + + await init_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("mock_airly_client") +async def test_config_without_unique_id(hass: HomeAssistant) -> None: """Test for setup entry without unique_id.""" entry = MockConfigEntry( domain=DOMAIN, title="Home", data={ - "api_key": "foo", - "latitude": 12.3, - "longitude": 45.6, + CONF_API_KEY: "foo", + CONF_LATITUDE: 12.3, + CONF_LONGITUDE: 45.6, }, ) - aioclient_mock.get( - API_POINT_URL, text=await async_load_fixture(hass, "valid_station.json", DOMAIN) - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) + await init_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED assert entry.unique_id == "12.3-45.6" async def test_config_with_turned_off_station( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_airly_client: MagicMock, + mock_airly_no_station_measurements: Measurement, ) -> None: """Test for setup entry for a turned off measuring station.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="Home", - unique_id="12.3-45.6", - data={ - "api_key": "foo", - "latitude": 12.3, - "longitude": 45.6, - }, + mock_airly_client.create_measurements_session_point.return_value.current = ( + mock_airly_no_station_measurements ) - aioclient_mock.get( - API_POINT_URL, text=await async_load_fixture(hass, "no_station.json", DOMAIN) - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY + await init_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY async def test_update_interval( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + mock_airly_client: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test correct update interval when the number of configured instances changes.""" REMAINING_REQUESTS = 15 - HEADERS = { - "X-RateLimit-Limit-day": "100", - "X-RateLimit-Remaining-day": str(REMAINING_REQUESTS), - } + mock_airly_client.requests_remaining = REMAINING_REQUESTS entry = MockConfigEntry( domain=DOMAIN, title="Home", unique_id="12.3-45.6", data={ - "api_key": "foo", - "latitude": 12.3, - "longitude": 45.6, + CONF_API_KEY: "foo", + CONF_LATITUDE: 12.3, + CONF_LONGITUDE: 45.6, }, ) - aioclient_mock.get( - API_POINT_URL, - text=await async_load_fixture(hass, "valid_station.json", DOMAIN), - headers=HEADERS, - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await init_integration(hass, entry) instances = 1 - assert aioclient_mock.call_count == 1 + create_measurements = mock_airly_client.create_measurements_session_point + update_measurements = create_measurements.return_value.update + assert create_measurements.call_count == 1 + assert update_measurements.call_count == 1 assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert entry.state is ConfigEntryState.LOADED @@ -141,8 +149,11 @@ async def test_update_interval( async_fire_time_changed(hass) await hass.async_block_till_done() - # call_count should increase by one because we have one instance configured - assert aioclient_mock.call_count == 2 + # update should be called once more because we have one instance configured. + # The measurements session is created once per entry, so create is not + # called again on refresh. + assert create_measurements.call_count == 1 + assert update_measurements.call_count == 2 # Now we add the second Airly instance entry = MockConfigEntry( @@ -150,23 +161,17 @@ async def test_update_interval( title="Work", unique_id="66.66-111.11", data={ - "api_key": "foo", - "latitude": 66.66, - "longitude": 111.11, + CONF_API_KEY: "foo", + CONF_LATITUDE: 66.66, + CONF_LONGITUDE: 111.11, }, ) - aioclient_mock.get( - "https://airapi.airly.eu/v2/measurements/point?lat=66.660000&lng=111.110000", - text=await async_load_fixture(hass, "valid_station.json", DOMAIN), - headers=HEADERS, - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await init_integration(hass, entry) instances = 2 - assert aioclient_mock.call_count == 3 + assert create_measurements.call_count == 2 + assert update_measurements.call_count == 3 assert len(hass.config_entries.async_entries(DOMAIN)) == 2 assert entry.state is ConfigEntryState.LOADED @@ -175,30 +180,34 @@ async def test_update_interval( async_fire_time_changed(hass) await hass.async_block_till_done() - # call_count should increase by two because we have two instances configured - assert aioclient_mock.call_count == 5 + # update should be called once more per instance because we have two + # instances configured + assert create_measurements.call_count == 2 + assert update_measurements.call_count == 5 +@pytest.mark.usefixtures("mock_airly_client") async def test_unload_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, ) -> None: """Test successful unload of entry.""" - entry = await init_integration(hass, aioclient_mock) + await init_integration(hass, mock_config_entry) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state is ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED - assert await hass.config_entries.async_unload(entry.entry_id) + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.NOT_LOADED + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) @pytest.mark.parametrize("old_identifier", [(DOMAIN, 123, 456), (DOMAIN, "123", "456")]) +@pytest.mark.usefixtures("mock_airly_client") async def test_migrate_device_entry( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, old_identifier: tuple[str, Any, Any], device_registry: dr.DeviceRegistry, ) -> None: @@ -208,15 +217,11 @@ async def test_migrate_device_entry( title="Home", unique_id="123-456", data={ - "api_key": "foo", - "latitude": 123, - "longitude": 456, + CONF_API_KEY: "foo", + CONF_LATITUDE: 123, + CONF_LONGITUDE: 456, }, ) - - aioclient_mock.get( - API_POINT_URL, text=await async_load_fixture(hass, "valid_station.json", DOMAIN) - ) config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( @@ -234,8 +239,9 @@ async def test_migrate_device_entry( async def test_remove_air_quality_entities( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, + mock_airly_client: MagicMock, ) -> None: """Test remove air_quality entities from registry.""" entity_registry.async_get_or_create( @@ -246,7 +252,7 @@ async def test_remove_air_quality_entities( disabled_by=None, ) - await init_integration(hass, aioclient_mock) + await init_integration(hass, mock_config_entry) entry = entity_registry.async_get("air_quality.home") assert entry is None diff --git a/tests/components/airly/test_sensor.py b/tests/components/airly/test_sensor.py index 1e723e40648c3..c211625dd926a 100644 --- a/tests/components/airly/test_sensor.py +++ b/tests/components/airly/test_sensor.py @@ -1,14 +1,14 @@ """Test sensor of Airly integration.""" +from collections.abc import Generator from datetime import timedelta from http import HTTPStatus -from unittest.mock import patch +from unittest.mock import MagicMock, patch from airly.exceptions import AirlyError import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.airly.const import DOMAIN from homeassistant.components.homeassistant import ( DOMAIN as HOMEASSISTANT_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -19,23 +19,31 @@ from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from . import API_POINT_URL, init_integration +from . import init_integration -from tests.common import async_fire_time_changed, async_load_fixture -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override PLATFORMS.""" + with patch("homeassistant.components.airly.PLATFORMS", [Platform.SENSOR]): + yield + + +@pytest.mark.usefixtures("mock_airly_client") async def test_sensor( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, ) -> None: """Test states of the sensor.""" - with patch("homeassistant.components.airly.PLATFORMS", [Platform.SENSOR]): - entry = await init_integration(hass, aioclient_mock) + await init_integration(hass, mock_config_entry) - entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) assert entity_entries for entity_entry in entity_entries: @@ -53,22 +61,22 @@ async def test_sensor( ) async def test_availability( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, + mock_airly_client: MagicMock, exception: Exception, ) -> None: """Ensure that we mark the entities unavailable correctly. Test when service is offline. """ - await init_integration(hass, aioclient_mock) + await init_integration(hass, mock_config_entry) state = hass.states.get("sensor.home_humidity") assert state assert state.state != STATE_UNAVAILABLE assert state.state == "68.35" - aioclient_mock.clear_requests() - aioclient_mock.get(API_POINT_URL, exc=exception) + mock_airly_client.create_measurements_session_point.return_value.update.side_effect = exception future = utcnow() + timedelta(minutes=60) async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -77,10 +85,7 @@ async def test_availability( assert state assert state.state == STATE_UNAVAILABLE - aioclient_mock.clear_requests() - aioclient_mock.get( - API_POINT_URL, text=await async_load_fixture(hass, "valid_station.json", DOMAIN) - ) + mock_airly_client.create_measurements_session_point.return_value.update.side_effect = None future = utcnow() + timedelta(minutes=120) async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -92,12 +97,15 @@ async def test_availability( async def test_manual_update_entity( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_airly_client: MagicMock, ) -> None: """Test manual update entity via service homeassistant/update_entity.""" - await init_integration(hass, aioclient_mock) + await init_integration(hass, mock_config_entry) - call_count = aioclient_mock.call_count + measurements = mock_airly_client.create_measurements_session_point.return_value + call_count = measurements.update.call_count await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) await hass.services.async_call( HOMEASSISTANT_DOMAIN, @@ -106,4 +114,4 @@ async def test_manual_update_entity( blocking=True, ) - assert aioclient_mock.call_count == call_count + 1 + assert measurements.update.call_count == call_count + 1 diff --git a/tests/components/airly/test_system_health.py b/tests/components/airly/test_system_health.py index 429d20f7d3333..df2015a198f76 100644 --- a/tests/components/airly/test_system_health.py +++ b/tests/components/airly/test_system_health.py @@ -1,6 +1,7 @@ """Test Airly system health.""" import asyncio +from unittest.mock import MagicMock from aiohttp import ClientError @@ -10,17 +11,20 @@ from . import init_integration -from tests.common import get_system_health_info +from tests.common import MockConfigEntry, get_system_health_info from tests.test_util.aiohttp import AiohttpClientMocker async def test_airly_system_health( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, + mock_airly_client: MagicMock, ) -> None: """Test Airly system health.""" aioclient_mock.get("https://airapi.airly.eu/v2/", text="") - await init_integration(hass, aioclient_mock) + await init_integration(hass, mock_config_entry) assert await async_setup_component(hass, "system_health", {}) await hass.async_block_till_done() @@ -36,12 +40,15 @@ async def test_airly_system_health( async def test_airly_system_health_fail( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, + mock_airly_client: MagicMock, ) -> None: """Test Airly system health.""" aioclient_mock.get("https://airapi.airly.eu/v2/", exc=ClientError) - await init_integration(hass, aioclient_mock) + await init_integration(hass, mock_config_entry) assert await async_setup_component(hass, "system_health", {}) await hass.async_block_till_done() From b5a6c533bb276befce6c290c512c66388d0d1001 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Fri, 18 Sep 2026 21:55:55 +0300 Subject: [PATCH 16/21] Cache English fallback translations for all components in a non-English batch (#182578) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/helpers/translation.py | 12 +++--- tests/helpers/test_translation.py | 64 ++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index f43970503feac..41f6854b17dc8 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -242,13 +242,15 @@ async def _async_load(self, language: str, components: set[str]) -> None: ) loaded_english_components = loaded.setdefault(LOCALE_EN, set()) - # Since we just loaded english anyway we can avoid loading - # again if they switch back to english. - if loaded_english_components.isdisjoint(components): + # English is the fallback for missing keys, so cache it for every + # not-yet-cached component, not only when the whole batch is new. + if english_to_cache := components - loaded_english_components: self._build_category_cache( - LOCALE_EN, components, translation_by_language_strings[LOCALE_EN] + LOCALE_EN, + english_to_cache, + translation_by_language_strings[LOCALE_EN], ) - loaded_english_components.update(components) + loaded_english_components.update(english_to_cache) loaded[language].update(components) diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index ef08ad3f42ea1..535d8d505cef2 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -807,3 +807,67 @@ async def test_get_translations_still_has_title_without_translations_files( assert translations == { "component.component1.title": "Component 1", } + + +async def test_english_cache_populated_for_partial_batch_overlap( + hass: HomeAssistant, +) -> None: + """Test English caching when a non-English batch partially overlaps it.""" + integration_a = Mock(file_path=pathlib.Path(__file__)) + integration_a.name = "Component A" + integration_b = Mock(file_path=pathlib.Path(__file__)) + integration_b.name = "Component B" + + component_strings = { + "comp_a": { + "en": {"issues": {"broken": {"title": "A is broken"}}}, + "de": {"issues": {"broken": {"title": "A kaputt"}}}, + }, + "comp_b": { + "en": {"issues": {"detached": {"title": "B detached"}}}, + "de": {"issues": {"detached": {"title": "B abgetrennt"}}}, + }, + } + + def mock_load_translation_files( + files: dict[str, dict[str, Any]], + ) -> dict[str, dict[str, Any]]: + """Return language-keyed strings for the requested components.""" + return { + language: { + component: component_strings[component][language] + for component in components + } + for language, components in files.items() + } + + cache = translation._async_get_translations_cache(hass) + + with ( + patch( + "homeassistant.helpers.translation._load_translations_files_by_language", + mock_load_translation_files, + ), + patch( + "homeassistant.helpers.translation.async_get_integrations", + return_value={"comp_a": integration_a, "comp_b": integration_b}, + ), + ): + # Bootstrap: comp_a is set up (and cached in English) first. + await cache.async_load("en", {"comp_a"}) + # Preload the full set in the real language; the batch overlaps comp_a. + await cache.async_load("de", {"comp_a", "comp_b"}) + + # comp_b's English strings were fetched during the "de" load and must be in + # the English cache even though comp_a was already cached in English. + assert translation.async_get_cached_translations( + hass, "en", "issues", "comp_b" + ) == {"component.comp_b.issues.detached.title": "B detached"} + # The already-cached component is unaffected. + assert translation.async_get_cached_translations( + hass, "en", "issues", "comp_a" + ) == {"component.comp_a.issues.broken.title": "A is broken"} + # The requested language keeps the localized strings on top of English. + assert translation.async_get_cached_translations( + hass, "de", "issues", "comp_b" + ) == {"component.comp_b.issues.detached.title": "B abgetrennt"} From b8fe74f56d97a55364bd619d1d87334dc35a157f Mon Sep 17 00:00:00 2001 From: darkrain-nl Date: Fri, 18 Sep 2026 21:02:18 +0200 Subject: [PATCH 17/21] Read the Sofar meter counters from meter_energy (#181729) --- homeassistant/components/sofar/sensor.py | 12 +-- tests/components/sofar/__init__.py | 13 +++ tests/components/sofar/test_init.py | 104 +++++++++++++++++++++++ tests/components/sofar/test_sensor.py | 20 +++-- 4 files changed, 134 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/sofar/sensor.py b/homeassistant/components/sofar/sensor.py index 6e6bcedfd6de2..70cece2e980d6 100644 --- a/homeassistant/components/sofar/sensor.py +++ b/homeassistant/components/sofar/sensor.py @@ -1153,7 +1153,7 @@ def _part_sensors( ), SofarSensorDescription( key="load_consumption_today", - component="energy", + component="meter_energy", translation_key="load_consumption_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -1163,7 +1163,7 @@ def _part_sensors( ), SofarSensorDescription( key="load_consumption_total", - component="energy", + component="meter_energy", translation_key="load_consumption_total", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -1172,7 +1172,7 @@ def _part_sensors( ), SofarSensorDescription( key="import_energy_today", - component="energy", + component="meter_energy", translation_key="import_energy_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -1182,7 +1182,7 @@ def _part_sensors( ), SofarSensorDescription( key="import_energy_total", - component="energy", + component="meter_energy", translation_key="import_energy_total", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -1191,7 +1191,7 @@ def _part_sensors( ), SofarSensorDescription( key="export_energy_today", - component="energy", + component="meter_energy", translation_key="export_energy_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -1201,7 +1201,7 @@ def _part_sensors( ), SofarSensorDescription( key="export_energy_total", - component="energy", + component="meter_energy", translation_key="export_energy_total", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, diff --git a/tests/components/sofar/__init__.py b/tests/components/sofar/__init__.py index f9eee46870447..6f2441cdc6dd7 100644 --- a/tests/components/sofar/__init__.py +++ b/tests/components/sofar/__init__.py @@ -50,6 +50,19 @@ def seed_pv_inverter(unit: MockModbusUnit, serial: str = MOCK_SERIAL) -> None: unit.holding[0x0685] = 1000 # solar_generation_today low word -> 10.0 kWh +def deny_meter_energy(unit: MockModbusUnit) -> None: + """Seed a model that denies the meter block from 0x0688 on.""" + # Bits 0-3 must stay set, or the mask is ignored and denies nothing. + unit.holding[0x0683] = 0x00FF + + +def serve_meter_energy(unit: MockModbusUnit) -> None: + """Seed a model that serves the whole meter block.""" + unit.holding[0x0683] = 0xFFFF # 0x0680-0x068F + unit.holding[0x0682] = 0x000F # 0x0690-0x0693 + unit.holding[0x068B] = 10000 # load_consumption_total -> 1000.0 kWh + + def seed_hybrid_inverter( unit: MockModbusUnit, serial: str = MOCK_HYBRID_SERIAL ) -> None: diff --git a/tests/components/sofar/test_init.py b/tests/components/sofar/test_init.py index be162081ff184..1e08aed2e1653 100644 --- a/tests/components/sofar/test_init.py +++ b/tests/components/sofar/test_init.py @@ -29,7 +29,9 @@ MOCK_SERIAL, MOCK_SW_VERSION, MOCK_USER_INPUT, + deny_meter_energy, seed_hybrid_inverter, + serve_meter_energy, ) from tests.common import ( @@ -178,6 +180,108 @@ async def test_setup_skips_seeding_an_unusable_restored_total( mock_seed.assert_not_called() +METER_ENERGY_KEYS = ( + "load_consumption_today", + "load_consumption_total", + "import_energy_today", + "import_energy_total", + "export_energy_today", + "export_energy_total", +) + + +async def test_setup_creates_no_meter_sensors_a_model_denies( + hass: HomeAssistant, + mock_connection: MockModbusConnection, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test an unmetered model publishes none of the meter sensors.""" + mock_config_entry.add_to_hass(hass) + deny_meter_energy(mock_connection.for_unit(1)) + + with patch( + "homeassistant.components.sofar.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + assert not any( + entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_{key}" + ) + for key in METER_ENERGY_KEYS + ) + # Solar generation shares the block but its own addresses stay valid. + assert ( + entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_solar_generation_total" + ) + is not None + ) + + +async def test_setup_keeps_meter_sensors_a_model_serves( + hass: HomeAssistant, + mock_connection: MockModbusConnection, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test a metered model keeps all six sensors and their real values.""" + mock_config_entry.add_to_hass(hass) + serve_meter_energy(mock_connection.for_unit(1)) + + with patch( + "homeassistant.components.sofar.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + assert all( + entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_{key}" + ) + for key in METER_ENERGY_KEYS + ) + entity_id = entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_load_consumption_total" + ) + assert hass.states.get(entity_id).state == "1000.0" + + +async def test_setup_keeps_meter_sensors_when_no_mask_is_published( + hass: HomeAssistant, + mock_connection: MockModbusConnection, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test a model publishing no usable mask keeps polling the meter block.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.sofar.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + assert all( + entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_{key}" + ) + is not None + for key in METER_ENERGY_KEYS + ) + + async def test_setup_entry_unrecognized_inverter_raises_setup_error( hass: HomeAssistant, ) -> None: diff --git a/tests/components/sofar/test_sensor.py b/tests/components/sofar/test_sensor.py index 413421cfabced..0da9e4a64ac1a 100644 --- a/tests/components/sofar/test_sensor.py +++ b/tests/components/sofar/test_sensor.py @@ -261,11 +261,11 @@ async def test_total_sensor_restore_data_parsing( device = runtime_data.readings.device description = SofarSensorDescription( key="load_consumption_total", - component="energy", + component="meter_energy", translation_key="load_consumption_total", ) - device.energy.load_consumption_total = None + device.meter_energy.load_consumption_total = None sensor = SofarTotalSensor(runtime_data, description) sensor.hass = hass sensor.async_get_last_sensor_data = AsyncMock( @@ -290,11 +290,11 @@ async def test_total_sensor_restore_data_parsing( await blank_sensor.async_added_to_hass() assert blank_sensor.native_value is None - device.energy.load_consumption_total = 120.0 + device.meter_energy.load_consumption_total = 120.0 total_sensor = SofarTotalSensor(runtime_data, description) assert total_sensor.native_value == 120.0 - device.energy.load_consumption_total = None + device.meter_energy.load_consumption_total = None unset_sensor = SofarTotalSensor(runtime_data, description) assert unset_sensor.native_value is None @@ -307,7 +307,7 @@ async def test_total_sensor_seeds_high_water_from_restored_value( device = runtime_data.readings.device description = SofarSensorDescription( key="load_consumption_total", - component="energy", + component="meter_energy", translation_key="load_consumption_total", state_class=SensorStateClass.TOTAL_INCREASING, ) @@ -316,7 +316,7 @@ async def test_total_sensor_seeds_high_water_from_restored_value( sensor.async_get_last_sensor_data = AsyncMock( return_value=SimpleNamespace(native_value="555.5") ) - with patch.object(device.energy, "seed_high_water") as mock_seed: + with patch.object(device.meter_energy, "seed_high_water") as mock_seed: await sensor.async_added_to_hass() mock_seed.assert_called_once_with("load_consumption_total", 555.5) @@ -342,7 +342,7 @@ async def test_total_sensor_dead_link_unavailable( runtime_data = init_integration.runtime_data description = SofarSensorDescription( key="load_consumption_total", - component="energy", + component="meter_energy", translation_key="load_consumption_total", state_class=SensorStateClass.TOTAL_INCREASING, ) @@ -390,13 +390,15 @@ async def test_total_sensor_total_increasing_uses_corrected_value( runtime_data = init_integration.runtime_data description = SofarSensorDescription( key="load_consumption_total", - component="energy", + component="meter_energy", translation_key="load_consumption_total", state_class=SensorStateClass.TOTAL_INCREASING, ) device = runtime_data.readings.device sensor = SofarTotalSensor(runtime_data, description) - with patch.object(device.energy, "corrected", return_value=42.0) as mock_corrected: + with patch.object( + device.meter_energy, "corrected", return_value=42.0 + ) as mock_corrected: assert sensor.native_value == 42.0 mock_corrected.assert_called_once_with("load_consumption_total") assert sensor.available From 4161a62e93390f1f0dc2dc6ef2b6726ddb905d81 Mon Sep 17 00:00:00 2001 From: Manu Date: Fri, 18 Sep 2026 21:05:08 +0200 Subject: [PATCH 18/21] Support attaching media from TTS sources in ntfy integration (#182140) --- homeassistant/components/ntfy/notify.py | 6 +++- homeassistant/components/ntfy/strings.json | 2 +- tests/components/ntfy/test_services.py | 40 ++++++++++++++++++++-- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ntfy/notify.py b/homeassistant/components/ntfy/notify.py index 23f923e100523..f0de43f8f9c80 100644 --- a/homeassistant/components/ntfy/notify.py +++ b/homeassistant/components/ntfy/notify.py @@ -11,7 +11,7 @@ NtfyUnauthorizedAuthenticationError, ) -from homeassistant.components import camera, image +from homeassistant.components import camera, image, tts from homeassistant.components.media_source import async_resolve_media from homeassistant.components.notify import ( NotifyEntity, @@ -99,6 +99,10 @@ async def _publish(self, **kwargs: Any) -> None: elif media_content_id.startswith("media-source://image/"): entity_id = media_content_id.removeprefix("media-source://image/") attachment = (await image.async_get_image(self.hass, entity_id)).content + elif media_content_id.startswith("media-source://tts/"): + _, attachment = await tts.async_get_media_source_audio( + self.hass, media_content_id + ) else: media = await async_resolve_media( self.hass, file["media_content_id"], None diff --git a/homeassistant/components/ntfy/strings.json b/homeassistant/components/ntfy/strings.json index 69b27e28be643..0882860a327fc 100644 --- a/homeassistant/components/ntfy/strings.json +++ b/homeassistant/components/ntfy/strings.json @@ -410,7 +410,7 @@ "name": "Attachment URL" }, "attach_file": { - "description": "Attach images or other files by uploading from a local file, camera, or image media source. When selecting a camera entity, a snapshot of the current view will be captured and attached to the notification.", + "description": "Attach images or other files by uploading from a local file, camera, text-to-speech, or image media source. When selecting a camera entity, a snapshot of the current view will be captured and attached to the notification.", "name": "Attach local file" }, "call": { diff --git a/tests/components/ntfy/test_services.py b/tests/components/ntfy/test_services.py index 09eceac06717e..632c7ea71bafd 100644 --- a/tests/components/ntfy/test_services.py +++ b/tests/components/ntfy/test_services.py @@ -399,7 +399,7 @@ async def test_ntfy_publish_upload_media_source_not_supported( patch( "homeassistant.components.ntfy.notify.async_resolve_media", return_value=media_source.PlayMedia( - url="/api/tts_proxy/WDyphPCh3sAoO3koDY87ew.mp3", + url="https://gameclipscontent-d2009.media.xboxlive.com/123456789", mime_type="audio/mpeg", path=None, ), @@ -415,8 +415,8 @@ async def test_ntfy_publish_upload_media_source_not_supported( { ATTR_ENTITY_ID: "notify.mytopic", ATTR_ATTACH_FILE: { - "media_content_id": "media-source://tts/demo?message=Hello+world%21&language=en", - "media_content_type": "audio/mp3", + "media_content_id": "media-source://xbox/123456789/", + "media_content_type": "video/mp4", }, }, blocking=True, @@ -455,6 +455,40 @@ async def test_ntfy_publish_upload_media_image_source( mock_aiontfy.publish.assert_called_once_with(Message(topic="mytopic"), b"\x89PNG") +@pytest.mark.usefixtures("mock_aiontfy") +async def test_ntfy_publish_upload_tts_source( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, +) -> None: + """Test publishing ntfy message with tts source.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + with patch( + "homeassistant.components.tts.async_get_media_source_audio", + return_value=("mp3", b"Test"), + ) as mock_get_media_source_audio: + await hass.services.async_call( + DOMAIN, + SERVICE_PUBLISH, + { + ATTR_ENTITY_ID: "notify.mytopic", + ATTR_ATTACH_FILE: { + "media_content_id": "media-source://tts/demo?message=Test&language=en", + "media_content_type": "audio/mp3", + }, + }, + blocking=True, + ) + mock_get_media_source_audio.assert_called_once_with( + hass, "media-source://tts/demo?message=Test&language=en" + ) + mock_aiontfy.publish.assert_called_once_with(Message(topic="mytopic"), b"Test") + + async def test_ntfy_clear( hass: HomeAssistant, config_entry: MockConfigEntry, From bcfa88e013f793e89b52e952e19565384d7372ed Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 18 Sep 2026 15:19:43 -0400 Subject: [PATCH 19/21] Retry Denon AVR setup on any receiver request error (#182613) Co-authored-by: Claude --- homeassistant/components/denonavr/__init__.py | 4 +- .../components/denonavr/test_media_player.py | 44 ++++++++++++++++--- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/denonavr/__init__.py b/homeassistant/components/denonavr/__init__.py index 09b084ec07cab..4475bcd86c646 100644 --- a/homeassistant/components/denonavr/__init__.py +++ b/homeassistant/components/denonavr/__init__.py @@ -3,7 +3,7 @@ import logging from denonavr import DenonAVR -from denonavr.exceptions import AvrNetworkError, AvrTimoutError +from denonavr.exceptions import AvrRequestError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform @@ -59,7 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DenonavrConfigEntry) -> ) try: await connect_denonavr.async_connect_receiver() - except (AvrNetworkError, AvrTimoutError) as ex: + except AvrRequestError as ex: raise ConfigEntryNotReady from ex receiver = connect_denonavr.receiver diff --git a/tests/components/denonavr/test_media_player.py b/tests/components/denonavr/test_media_player.py index ce204c1ec5644..68be058f58388 100644 --- a/tests/components/denonavr/test_media_player.py +++ b/tests/components/denonavr/test_media_player.py @@ -1,7 +1,8 @@ """The tests for the denonavr media player platform.""" +from collections.abc import Generator from datetime import timedelta -from unittest.mock import patch +from unittest.mock import MagicMock, patch from denonavr.exceptions import AvrIncompleteResponseError, AvrInvalidResponseError from freezegun.api import FrozenDateTimeFactory @@ -14,13 +15,14 @@ CONF_TYPE, DOMAIN, ) -from homeassistant.components.denonavr.const import ATTR_DYNAMIC_EQ +from homeassistant.components.denonavr.const import ATTR_DYNAMIC_EQ, CONF_USE_TELNET from homeassistant.components.denonavr.services import ( ATTR_COMMAND, SERVICE_GET_COMMAND, SERVICE_SET_DYNAMIC_EQ, SERVICE_UPDATE_AUDYSSEY, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_MODEL, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -43,7 +45,7 @@ @pytest.fixture(name="client") -def client_fixture(): +def client_fixture() -> Generator[MagicMock]: """Patch of client library for tests.""" with ( patch( @@ -107,7 +109,7 @@ async def test_setup_without_serial_number( ) -async def test_get_command(hass: HomeAssistant, client) -> None: +async def test_get_command(hass: HomeAssistant, client: MagicMock) -> None: """Test generic command functionality.""" await setup_denonavr(hass) @@ -121,7 +123,7 @@ async def test_get_command(hass: HomeAssistant, client) -> None: client.async_get_command.assert_awaited_with("test_command") -async def test_dynamic_eq(hass: HomeAssistant, client) -> None: +async def test_dynamic_eq(hass: HomeAssistant, client: MagicMock) -> None: """Test that dynamic eq method works.""" await setup_denonavr(hass) @@ -142,7 +144,7 @@ async def test_dynamic_eq(hass: HomeAssistant, client) -> None: client.async_dynamic_eq_off.assert_called_once() -async def test_update_audyssey(hass: HomeAssistant, client) -> None: +async def test_update_audyssey(hass: HomeAssistant, client: MagicMock) -> None: """Test that dynamic eq method works.""" await setup_denonavr(hass) @@ -159,6 +161,34 @@ async def test_update_audyssey(hass: HomeAssistant, client) -> None: client.async_update_audyssey.assert_called_once() +async def test_setup_retry_on_request_error( + hass: HomeAssistant, client: MagicMock +) -> None: + """Test that a failed request during setup retries the config entry.""" + client.async_update.side_effect = AvrInvalidResponseError( + "Server disconnected without sending a response", "GET" + ) + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_UNIQUE_ID, + data={ + CONF_HOST: TEST_HOST, + CONF_MODEL: TEST_MODEL, + CONF_TYPE: TEST_RECEIVER_TYPE, + CONF_MANUFACTURER: TEST_MANUFACTURER, + CONF_SERIAL_NUMBER: TEST_SERIALNUMBER, + }, + options={CONF_USE_TELNET: True}, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + + @pytest.mark.parametrize( "exception", [ @@ -174,7 +204,7 @@ async def test_update_audyssey(hass: HomeAssistant, client) -> None: ) async def test_malformed_response_marks_unavailable( hass: HomeAssistant, - client, + client: MagicMock, freezer: FrozenDateTimeFactory, exception: Exception, ) -> None: From b14a51382d1d0b9b6aa6c26a6716e0b0a9211ef0 Mon Sep 17 00:00:00 2001 From: Manu Date: Fri, 18 Sep 2026 21:21:53 +0200 Subject: [PATCH 20/21] Move websocket registration to async_setup in HTML5 integration (#181859) --- homeassistant/components/html5/__init__.py | 2 + homeassistant/components/html5/notify.py | 21 +--------- .../components/html5/websocket_api.py | 38 +++++++++++++++++++ tests/components/html5/test_websocket_api.py | 32 ++++++++++++++++ 4 files changed, 73 insertions(+), 20 deletions(-) create mode 100644 homeassistant/components/html5/websocket_api.py create mode 100644 tests/components/html5/test_websocket_api.py diff --git a/homeassistant/components/html5/__init__.py b/homeassistant/components/html5/__init__.py index 5cd10a98a273b..d48efaaf7e5ff 100644 --- a/homeassistant/components/html5/__init__.py +++ b/homeassistant/components/html5/__init__.py @@ -8,6 +8,7 @@ from .const import DOMAIN from .services import async_setup_services +from .websocket_api import async_register_websocket_api CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -18,6 +19,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HTML5 services.""" async_setup_services(hass) + async_register_websocket_api(hass) return True diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index a172e77e36559..2b66e1e3eea61 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -18,7 +18,6 @@ from py_vapid import Vapid from pywebpush import WebPusher, WebPushException, webpush_async -from homeassistant.components import websocket_api from homeassistant.components.notify import ( ATTR_DATA, ATTR_TARGET, @@ -28,10 +27,9 @@ NotifyEntity, NotifyEntityFeature, ) -from homeassistant.components.websocket_api import ActiveConnection from homeassistant.config_entries import ConfigEntry from homeassistant.const import URL_ROOT -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -48,7 +46,6 @@ ATTR_TTL, ATTR_VAPID_EMAIL, ATTR_VAPID_PRV_KEY, - ATTR_VAPID_PUB_KEY, DOMAIN, REGISTRATIONS_FILE, SERVICE_DISMISS, @@ -71,11 +68,6 @@ ATTR_JWT = "jwt" -WS_TYPE_APPKEY = "notify/html5/appkey" -SCHEMA_WS_APPKEY = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {probatio.Required("type"): WS_TYPE_APPKEY} -) - # The number of days after the moment a notification is sent that a JWT # is valid. JWT_VALID_DAYS = 7 @@ -123,20 +115,9 @@ async def async_get_service( registrations = await hass.async_add_executor_job(_load_config, json_path) - vapid_pub_key: str = discovery_info[ATTR_VAPID_PUB_KEY] vapid_prv_key: str = discovery_info[ATTR_VAPID_PRV_KEY] vapid_email: str = discovery_info[ATTR_VAPID_EMAIL] - @callback - def websocket_appkey( - _hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] - ) -> None: - connection.send_message(websocket_api.result_message(msg["id"], vapid_pub_key)) - - websocket_api.async_register_command( - hass, WS_TYPE_APPKEY, websocket_appkey, SCHEMA_WS_APPKEY - ) - async_register_http_views(hass, json_path, registrations) session = async_get_clientsession(hass) diff --git a/homeassistant/components/html5/websocket_api.py b/homeassistant/components/html5/websocket_api.py new file mode 100644 index 0000000000000..49aeeb894b2ea --- /dev/null +++ b/homeassistant/components/html5/websocket_api.py @@ -0,0 +1,38 @@ +"""HTML5 Websocket API.""" + +from typing import Any + +import probatio + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.service import async_get_config_entry + +from .const import ATTR_VAPID_PUB_KEY, DOMAIN + +WS_TYPE_APPKEY = "notify/html5/appkey" + + +@callback +def async_register_websocket_api(hass: HomeAssistant) -> None: + """Register the websocket API.""" + + websocket_api.async_register_command(hass, websocket_appkey) + + +@websocket_api.websocket_command( + { + probatio.Required("type"): WS_TYPE_APPKEY, + } +) +@websocket_api.async_response +async def websocket_appkey( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Handle request for the VAPID public key.""" + entry = async_get_config_entry(hass, DOMAIN, None) + connection.send_message( + websocket_api.result_message(msg["id"], entry.data[ATTR_VAPID_PUB_KEY]) + ) diff --git a/tests/components/html5/test_websocket_api.py b/tests/components/html5/test_websocket_api.py new file mode 100644 index 0000000000000..f728dcb5b2ee4 --- /dev/null +++ b/tests/components/html5/test_websocket_api.py @@ -0,0 +1,32 @@ +"""Tests for HTML5 Websocket API.""" + +from homeassistant.components.html5.websocket_api import WS_TYPE_APPKEY +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import MOCK_CONF_PUB_KEY + +from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator + + +async def test_websocket_appkey( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test websocket appkey command.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": WS_TYPE_APPKEY}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == MOCK_CONF_PUB_KEY From 11a2f455d253830415e2e62c2a4db201dfd7cfc8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Sep 2026 16:21:22 -0500 Subject: [PATCH 21/21] Bump aioharmony to 1.0.10 (#182642) --- homeassistant/components/harmony/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index 1d01a215614e7..7490262203a1d 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioharmony", "slixmpp"], - "requirements": ["aioharmony==1.0.8"], + "requirements": ["aioharmony==1.0.10"], "ssdp": [ { "deviceType": "urn:myharmony-com:device:harmony:1", diff --git a/requirements_all.txt b/requirements_all.txt index 8e32b3132ec7e..6be7c749bc93d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -288,7 +288,7 @@ aioguardian==2026.01.1 aioharmanluxury==0.2.3 # homeassistant.components.harmony -aioharmony==1.0.8 +aioharmony==1.0.10 # homeassistant.components.hassio aiohasupervisor==0.6.0