From ef02b6213156605d7a9729e1e3097212069d9532 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 18 Sep 2026 13:17:54 +0200 Subject: [PATCH 01/26] Make HTTP security filter scan cost track request target length (#182570) --- .../components/http/security_filter.py | 10 +- tests/components/http/test_security_filter.py | 95 ++++++++++++++++++- 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/http/security_filter.py b/homeassistant/components/http/security_filter.py index a1a7e23423f0d9..c32530c6142607 100644 --- a/homeassistant/components/http/security_filter.py +++ b/homeassistant/components/http/security_filter.py @@ -14,21 +14,23 @@ _LOGGER = logging.getLogger(__name__) # fmt: off +# `.*` between literals would make the engine rescan once per start position. +# The atomic scans and line anchors keep this linear in the request line length. FILTERS: Final = re.compile( r"(?:" # Common exploits r"proc/self/environ" - r"|(<|%3C).*script.*(>|%3E)" + r"|(?m:^(?>[^\n]*?(?:<|%3C))(?>[^\n]*?script)(?>[^\n]*?(?:>|%3E)))" # File Injections r"|(\.\.//?)+" # ../../anywhere r"|[a-zA-Z0-9_]=/([a-z0-9_.]//?)+" # .html?v=/.//test # SQL Injections - r"|union.*select.*\(" - r"|union.*all.*select.*" - r"|concat.*\(" + r"|(?m:^(?>[^\n]*?union)(?>[^\n]*?select)(?>[^\n]*?\())" + r"|(?m:^(?>[^\n]*?union)(?>[^\n]*?all)(?>[^\n]*?select))" + r"|(?m:^(?>[^\n]*?concat)(?>[^\n]*?\())" r")", flags=re.IGNORECASE, diff --git a/tests/components/http/test_security_filter.py b/tests/components/http/test_security_filter.py index c5b928b426fcf8..b57834a0e0d0a7 100644 --- a/tests/components/http/test_security_filter.py +++ b/tests/components/http/test_security_filter.py @@ -2,12 +2,14 @@ import asyncio from http import HTTPStatus +import time from aiohttp import web import pytest import urllib3 -from homeassistant.components.http.security_filter import setup_security_filter +from homeassistant.components.http.security_filter import FILTERS, setup_security_filter +from homeassistant.components.http.server import MAX_LINE_SIZE from tests.typing import ClientSessionGenerator @@ -17,6 +19,12 @@ async def mock_handler(request): return web.Response(text="OK") +def long_target(prefix: str, unit: str, suffix: str = "") -> str: + """Build a request target that fills up the maximum request line size.""" + room = MAX_LINE_SIZE - len(prefix) - len(suffix) + return prefix + unit * (room // len(unit)) + suffix + + @pytest.mark.parametrize( ("request_path", "request_params"), [ @@ -158,3 +166,88 @@ async def test_bad_requests_with_unsafe_bytes( if fail_on_query_string: message = "Filtered a request with unsafe byte query string:" assert message in caplog.text + + +@pytest.mark.parametrize( + "request_target", + [ + pytest.param(long_target("/?a=<", "script"), id="unclosed_script_tag"), + pytest.param( + long_target("/?a=" + "<" * 64, "script"), id="many_opening_brackets" + ), + pytest.param( + long_target("/?a=" + "%3C" * 64, "script"), id="many_encoded_brackets" + ), + pytest.param( + long_target("/?a=" + "union" * 8, "all"), id="union_without_select" + ), + pytest.param(long_target("/?a=union", "select"), id="union_without_paren"), + pytest.param(long_target("/?a=", "concat"), id="concat_without_paren"), + pytest.param(long_target("/?a=", " None: + """Test that a long request target that does not match stays cheap to filter. + + The bound is loose on purpose: it should fail when a branch starts + backtracking again, not measure the machine it runs on. It goes at the + pattern directly because the test server caps the request line well below + MAX_LINE_SIZE, which also means the %3C cases arrive still encoded. + """ + start = time.perf_counter() + match = FILTERS.search(request_target) + duration = time.perf_counter() - start + + assert match is None + assert duration < 1 + + +@pytest.mark.parametrize( + "request_target", + [ + pytest.param(long_target("/?a=<", "script", ">"), id="closed_script_tag"), + pytest.param(long_target("/?a=%3C", "script", "%3E"), id="encoded_script_tag"), + pytest.param(long_target("/?a=union", "all", "select"), id="union_all_select"), + pytest.param(long_target("/?a=", "concat", "("), id="concat_paren"), + ], +) +def test_long_filtered_targets_still_match(request_target: str) -> None: + """Test that a long request target that should be filtered still matches.""" + assert FILTERS.search(request_target) is not None + + +@pytest.mark.parametrize( + "request_path", + [ + "/%3Cscript%3Ealert%3C/script%3E", + "/%253Cscript%253E", + "/%25253Cscript%25253E", + "/%2525253Cscript%2525253E", + ], +) +async def test_multiple_encoded_script_tags( + request_path: str, + aiohttp_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that repeatedly encoded script tags are unquoted and then filtered.""" + app = web.Application() + app.router.add_get("/{all:.*}", mock_handler) + + setup_security_filter(app) + + mock_api_client = await aiohttp_client(app) + + http = urllib3.PoolManager() + resp = await asyncio.get_running_loop().run_in_executor( + None, + http.request, + "GET", + f"http://{mock_api_client.host}:{mock_api_client.port}{request_path}", + ) + + assert resp.status == HTTPStatus.BAD_REQUEST + assert "Filtered a potential harmful request to:" in caplog.text From 843d9a7ea0c454fe892104971b73cfe2f38c1d9f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:30:10 +0200 Subject: [PATCH 02/26] Bump home-assistant/builder/actions/build-image from 2026.06.0 to 2026.09.0 (#182558) Signed-off-by: dependabot[bot] --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 2f522c6a73f502..8a3ee93caa6a54 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -198,7 +198,7 @@ jobs: echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE - name: Build base image - uses: home-assistant/builder/actions/build-image@4de35182ce1e329181bffcbcc84d33db5e2c7e10 # 2026.06.0 + uses: home-assistant/builder/actions/build-image@7412f0023ea9b6e58e8bb5059f1660f51376f49a # 2026.09.0 with: arch: ${{ matrix.arch }} build-args: | @@ -256,7 +256,7 @@ jobs: fi - name: Build machine image - uses: home-assistant/builder/actions/build-image@4de35182ce1e329181bffcbcc84d33db5e2c7e10 # 2026.06.0 + uses: home-assistant/builder/actions/build-image@7412f0023ea9b6e58e8bb5059f1660f51376f49a # 2026.09.0 with: arch: ${{ matrix.arch }} build-args: | From 26a3094d608eea3647c4fc7e05d77a1cccb333c3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 18 Sep 2026 09:05:55 -0400 Subject: [PATCH 03/26] Use ToolResult in anthropic (#182542) Co-authored-by: Claude --- homeassistant/components/anthropic/entity.py | 33 +++++++++++-------- .../snapshots/test_conversation.ambr | 15 ++++++--- .../components/anthropic/test_conversation.py | 2 ++ 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index 87a4340016f1ac..89c9aa3d5a5ef0 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -245,11 +245,11 @@ def _convert_content( # noqa: C901 "tool_use_id": content.tool_call_id, "content": cast( WebSearchToolResultBlockParamContentParam, - content.tool_result["content"] - if "content" in content.tool_result + content.result.data["content"] + if "content" in content.result.data else { "type": "web_search_tool_result_error", - "error_code": content.tool_result.get( + "error_code": content.result.data.get( "error_code", "unavailable" ), }, @@ -261,7 +261,7 @@ def _convert_content( # noqa: C901 "tool_use_id": content.tool_call_id, "content": cast( CodeExecutionToolResultBlockParamContentParam, - content.tool_result, + content.result.data, ), } elif content.tool_name == "bash_code_execution": @@ -270,7 +270,7 @@ def _convert_content( # noqa: C901 "tool_use_id": content.tool_call_id, "content": cast( BashCodeExecutionToolResultBlockParamContentParam, - content.tool_result, + content.result.data, ), } elif content.tool_name == "text_editor_code_execution": @@ -279,7 +279,7 @@ def _convert_content( # noqa: C901 "tool_use_id": content.tool_call_id, "content": cast( TextEditorCodeExecutionToolResultBlockParamContentParam, - content.tool_result, + content.result.data, ), } elif content.tool_name == "tool_search": @@ -288,7 +288,7 @@ def _convert_content( # noqa: C901 "tool_use_id": content.tool_call_id, "content": cast( ToolSearchToolResultBlockParamContentParam, - content.tool_result, + content.result.data, ), } elif content.tool_name == "web_fetch": @@ -297,14 +297,15 @@ def _convert_content( # noqa: C901 "tool_use_id": content.tool_call_id, "content": cast( WebFetchToolResultBlockParamContentParam, - content.tool_result, + content.result.data, ), } else: tool_result_block = { "type": "tool_result", "tool_use_id": content.tool_call_id, - "content": json_dumps(content.tool_result), + "content": json_dumps(content.result.data), + "is_error": content.result.error, } external_tool = False if not messages or messages[-1]["role"] != ( @@ -780,11 +781,15 @@ def on_server_tool_result_block( "role": "tool_result", "tool_call_id": tool_use_id, "tool_name": tool_name.removesuffix("_tool_result"), - "tool_result": { - "content": cast(JsonArrayType, [x.to_dict() for x in content]) - } - if isinstance(content, list) - else cast(JsonObjectType, content.to_dict()), + "result": llm.ToolResult( + data={ + "content": cast(JsonArrayType, [x.to_dict() for x in content]) + } + if isinstance(content, list) + else cast(JsonObjectType, content.to_dict()), + error=not isinstance(content, list) + and content.type.endswith("_tool_result_error"), + ), } ) self._first_block = True diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index 5f81ce18156e6f..bfd40273942751 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -141,7 +141,7 @@ 'error_code': 'unavailable', 'type': 'bash_code_execution_tool_result_error', }), - 'error': False, + 'error': True, }), 'role': 'tool_result', 'tool_call_id': 'srvtoolu_12345ABC', @@ -500,6 +500,7 @@ 'content': list([ dict({ 'content': '"Test response"', + 'is_error': False, 'tool_use_id': 'toolu_0123456789AbCdEfGhIjKlM', 'type': 'tool_result', }), @@ -666,11 +667,13 @@ }), dict({ 'content': '{"success":true,"response":"Lights are off."}', + 'is_error': False, 'tool_use_id': 'mock-tool-call-id', 'type': 'tool_result', }), dict({ 'content': '{"success":false,"response":"Not enough milk."}', + 'is_error': False, 'tool_use_id': 'mock-tool-call-id-2', 'type': 'tool_result', }), @@ -922,6 +925,7 @@ 'content': list([ dict({ 'content': '{"speech_slots":{"time":"14:30:00"},"message":"Current time retrieved"}', + 'is_error': False, 'tool_use_id': 'mock-tool-call-id', 'type': 'tool_result', }), @@ -1008,6 +1012,7 @@ 'content': list([ dict({ 'content': '{"speech":{"plain":{"speech":"The Hygrostat is set to 50%","extra_data":null}},"response_type":"action_done","data":{"success":[],"failed":[]}}', + 'is_error': False, 'tool_use_id': 'toolu_01KNRWb3ZFufCa7WXtzCakhc', 'type': 'tool_result', }), @@ -1439,7 +1444,7 @@ 'error_message': 'Tool response parsing error for view: Failed to parse tool response as JSON: unexpected character: line 1 column 1 (char 0)', 'type': 'text_editor_code_execution_tool_result_error', }), - 'error': False, + 'error': True, }), 'role': 'tool_result', 'tool_call_id': 'srvtoolu_12345ABC', @@ -1696,7 +1701,7 @@ 'error_code': 'too_many_requests', 'type': 'tool_search_tool_result_error', }), - 'error': False, + 'error': True, }), 'role': 'tool_result', 'tool_call_id': 'srvtoolu_12345ABC', @@ -2037,7 +2042,7 @@ 'error_code': 'url_not_allowed', 'type': 'web_fetch_tool_result_error', }), - 'error': False, + 'error': True, }), 'role': 'tool_result', 'tool_call_id': 'srvtoolu_12345ABC', @@ -2574,7 +2579,7 @@ 'error_code': 'too_many_requests', 'type': 'web_search_tool_result_error', }), - 'error': False, + 'error': True, }), 'role': 'tool_result', 'tool_call_id': 'srvtoolu_12345ABC', diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 787e81e541c757..b1b409ab052a35 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -473,6 +473,7 @@ async def test_function_call( "content": [ { "content": '"Test response"', + "is_error": False, "tool_use_id": "toolu_0123456789AbCdEfGhIjKlM", "type": "tool_result", } @@ -550,6 +551,7 @@ async def test_function_exception( "content": ( '{"error":"HomeAssistantError","error_text":"Test tool exception"}' ), + "is_error": True, "tool_use_id": "toolu_0123456789AbCdEfGhIjKlM", "type": "tool_result", } From 907abfaeb64822dbdd730812d3828b9b6e48748c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 18 Sep 2026 15:14:44 +0200 Subject: [PATCH 04/26] Fix Yeelight ambilight failing to update while its color mode is not reported (#182587) --- homeassistant/components/yeelight/light.py | 8 +++- tests/components/yeelight/test_light.py | 54 ++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 5bf123414b789b..0464eba2b291c1 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -742,7 +742,7 @@ async def async_set_flash(self, flash) -> None: """Activate flash.""" if not flash: return - if int(self._get_property("color_mode")) != 1 or not self.hs_color: + if self.color_mode is not ColorMode.RGB or not self.hs_color: _LOGGER.error("Flash supported currently only in RGB mode") return @@ -920,7 +920,11 @@ class YeelightColorLightSupport(YeelightBaseLight): @override def color_mode(self) -> ColorMode: """Return the color mode.""" - color_mode = int(self._get_property("color_mode")) + raw_color_mode = self._get_property("color_mode") + if raw_color_mode is None: + # an ambilight stops reporting its mode while the main light is off + return ColorMode.UNKNOWN + color_mode = int(raw_color_mode) if color_mode == 1: # RGB return ColorMode.RGB if color_mode == 2: # color temperature diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index ee60c4af8efef5..385afad58fa6ca 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -30,6 +30,7 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, @@ -40,6 +41,7 @@ FLASH_SHORT, SERVICE_TURN_OFF, SERVICE_TURN_ON, + ColorMode, LightEntityFeature, ) from homeassistant.components.yeelight.const import ( @@ -1333,6 +1335,58 @@ async def _async_get_stopped_properties(properties: list[str]) -> None: assert ambilight_state.attributes["flowing"] is False +async def test_ambilight_without_color_mode( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the ambilight keeps updating while the bulb does not report its mode. + + A ceiling light stops reporting `bg_lmode` while its main light is off. + """ + assert await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) + mocked_bulb = _mocked_bulb() + capabilities = {**CAPABILITIES, "model": "ceiling10"} + properties = {**PROPERTIES} + properties.pop("bg_lmode") + mocked_bulb.last_properties = properties + mocked_bulb.bulb_type = BulbType.WhiteTempMood + + config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_ENTRY_DATA) + config_entry.add_to_hass(hass) + with ( + _patch_discovery(capabilities=capabilities), + _patch_discovery_interval(), + patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + ambilight_entity_id = "light.yeelight_ceiling10_0x15243f_ambilight" + state = hass.states.get(ambilight_entity_id) + assert state.state == STATE_ON + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.UNKNOWN + + # flash needs the mode, so it is refused instead of raising + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ambilight_entity_id, ATTR_FLASH: FLASH_LONG}, + blocking=True, + ) + mocked_bulb.async_start_flow.assert_not_called() + assert "Flash supported currently only in RGB mode" in caplog.text + + # the mode is reported again once the main light is back on + mocked_bulb.last_properties["bg_lmode"] = "2" + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ambilight_entity_id}, + blocking=True, + ) + state = hass.states.get(ambilight_entity_id) + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP + + async def test_state_fails_to_update_triggers_update(hass: HomeAssistant) -> None: """Ensure async_get_properties is called on failed state update.""" mocked_bulb = _mocked_bulb() From 8a967b582ea8d7af3f2a67f6b8d494215a6f8197 Mon Sep 17 00:00:00 2001 From: Chris <53621277+smitec11@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:21:26 +0200 Subject: [PATCH 05/26] Split user flow init and data submission in ipp config flow tests (#182589) Co-authored-by: Claude Opus 5 --- tests/components/ipp/test_config_flow.py | 48 +++++++++++++++--------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/tests/components/ipp/test_config_flow.py b/tests/components/ipp/test_config_flow.py index f38d073f990eb7..0411d4b5a50edf 100644 --- a/tests/components/ipp/test_config_flow.py +++ b/tests/components/ipp/test_config_flow.py @@ -69,9 +69,11 @@ async def test_connection_error( user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=user_input ) assert result["step_id"] == "user" @@ -122,9 +124,11 @@ async def test_user_connection_upgrade_required( user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=user_input ) assert result["step_id"] == "user" @@ -159,9 +163,11 @@ async def test_user_parse_error( user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=user_input ) assert result["type"] is FlowResultType.ABORT @@ -195,9 +201,11 @@ async def test_user_ipp_error( user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=user_input ) assert result["type"] is FlowResultType.ABORT @@ -231,9 +239,11 @@ async def test_user_ipp_version_error( user_input = {**MOCK_USER_INPUT} result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=user_input ) assert result["type"] is FlowResultType.ABORT @@ -268,9 +278,11 @@ async def test_user_device_exists_abort( user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=user_input ) assert result["type"] is FlowResultType.ABORT From 18bfbc2bd48da4bbc48890d103c597787bae69c7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 18 Sep 2026 15:22:49 +0200 Subject: [PATCH 06/26] Fix Hue MotionAware entities for zones covering the whole home (#182586) --- homeassistant/components/hue/switch.py | 9 +-- .../components/hue/v2/binary_sensor.py | 9 +-- homeassistant/components/hue/v2/helpers.py | 20 +++++ tests/components/hue/conftest.py | 10 +++ tests/components/hue/test_binary_sensor.py | 77 ++++++++++++++++--- tests/components/hue/test_switch.py | 58 +++++++++++--- 6 files changed, 148 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/hue/switch.py b/homeassistant/components/hue/switch.py index caf4ca0008c6e3..e336da147a9cef 100644 --- a/homeassistant/components/hue/switch.py +++ b/homeassistant/components/hue/switch.py @@ -28,12 +28,12 @@ from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .bridge import HueBridge, HueConfigEntry from .const import DOMAIN from .v2.entity import HueBaseEntity +from .v2.helpers import get_motion_area_device_info async def async_setup_entry( @@ -229,11 +229,8 @@ def __init__( ) -> None: """Initialize the switch.""" super().__init__(bridge, controller, resource) - # link the switch to the group the MotionAware zone is associated with - self.hue_group = controller.get_group(resource.id) - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.hue_group.id)}, - ) + # link the switch to the room or zone the MotionAware zone is associated with + self._attr_device_info = get_motion_area_device_info(bridge.api, resource) class HueMotionSensorEnabledEntity(HueResourceEnabledEntity): diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py index 07806f909493ec..b1d02c85d11a8a 100644 --- a/homeassistant/components/hue/v2/binary_sensor.py +++ b/homeassistant/components/hue/v2/binary_sensor.py @@ -41,6 +41,7 @@ from ..bridge import HueBridge, HueConfigEntry from ..const import DOMAIN from .entity import HueBaseEntity +from .helpers import get_motion_area_device_info type SensorType = ( CameraMotion @@ -230,14 +231,12 @@ def __init__( ) -> None: """Initialize the sensor.""" super().__init__(bridge, controller, resource) - # link the MotionAware sensor to the group the sensor is associated with self._motion_area_configuration = self.controller.get_motion_area_configuration( resource.id ) - group_id = self._motion_area_configuration.group.rid - self.hue_group = self.bridge.api.groups[group_id] - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.hue_group.id)}, + # link the MotionAware sensor to the room or zone it is associated with + self._attr_device_info = get_motion_area_device_info( + self.bridge.api, self._motion_area_configuration ) @override diff --git a/homeassistant/components/hue/v2/helpers.py b/homeassistant/components/hue/v2/helpers.py index dd0c2c97f911b1..10613eb029a0da 100644 --- a/homeassistant/components/hue/v2/helpers.py +++ b/homeassistant/components/hue/v2/helpers.py @@ -1,7 +1,13 @@ """Helper functions for Philips Hue v2.""" +from aiohue.v2 import HueBridgeV2 +from aiohue.v2.models.motion_area_configuration import MotionAreaConfiguration + +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.util import color as color_util +from ..const import DOMAIN + def normalize_hue_brightness(brightness: float | None) -> float | None: """Return calculated brightness values.""" @@ -30,3 +36,17 @@ def normalize_hue_colortemp( colortemp_mireds = color_util.color_temperature_kelvin_to_mired(colortemp_k) # Hue only accepts a range between min_mireds..max_mireds return min(max(colortemp_mireds, min_mireds), max_mireds) + + +def get_motion_area_device_info( + api: HueBridgeV2, motion_area: MotionAreaConfiguration +) -> DeviceInfo: + """Return the device the entities of a MotionAware zone belong to. + + Rooms and zones each have a device of their own. A MotionAware zone that + covers the whole home points at `bridge_home` instead, which has none, so + its entities are attached to the bridge. + """ + if (group := api.groups.get(motion_area.group.rid)) is None: + return DeviceInfo(identifiers={(DOMAIN, api.config.bridge_id)}) + return DeviceInfo(identifiers={(DOMAIN, group.id)}) diff --git a/tests/components/hue/conftest.py b/tests/components/hue/conftest.py index 26a379a5ac97d3..2f16d88580c2a4 100644 --- a/tests/components/hue/conftest.py +++ b/tests/components/hue/conftest.py @@ -147,6 +147,16 @@ def v2_resources_test_data() -> JsonArrayType: return load_json_array_fixture("hue/v2_resources.json") +def replace_resources( + data: JsonArrayType, resources: list[dict[str, Any]] +) -> JsonArrayType: + """Return the test data with each resource of the same id replaced.""" + replacements = {resource["id"]: resource for resource in resources} + missing = replacements.keys() - {resource["id"] for resource in data} + assert not missing, f"resource id(s) not present in the test data: {missing}" + return [replacements.get(resource["id"], resource) for resource in data] + + def create_mock_api_v2() -> Mock: """Create a mock V2 API.""" api = Mock(spec=aiohue_v2.HueBridgeV2) diff --git a/tests/components/hue/test_binary_sensor.py b/tests/components/hue/test_binary_sensor.py index 32fa0df31365c7..902c068bd8cae1 100644 --- a/tests/components/hue/test_binary_sensor.py +++ b/tests/components/hue/test_binary_sensor.py @@ -5,15 +5,24 @@ import pytest +from homeassistant.components.hue.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util.json import JsonArrayType -from .conftest import setup_platform -from .const import FAKE_BINARY_SENSOR, FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY +from .conftest import replace_resources, setup_platform +from .const import ( + FAKE_BINARY_SENSOR, + FAKE_BRIDGE, + FAKE_DEVICE, + FAKE_ZIGBEE_CONNECTIVITY, +) MOTION_AWARE_ENTITY_ID = "binary_sensor.test_room_test_room_motion_aware_sensor_1" MOTION_AREA_CONFIGURATION_ID = "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b" +TEST_ROOM_ID = "6ddc9066-7e7d-4a03-a773-c73937968296" +BRIDGE_HOME_ID = "a3fbc86a-bf4c-4c69-899d-d6eafc37e288" AREA_MOTION_SERVICE_IDS = { "convenience_area_motion": "4f317b69-9da0-4b4f-84f2-7ca07b9fe345", "security_area_motion": "8b7e4f82-9c3d-4e1a-a5f6-8d9c7b2a3e4f", @@ -57,16 +66,6 @@ def area_motion_service( return service -def replace_resources( - data: JsonArrayType, resources: list[dict[str, Any]] -) -> JsonArrayType: - """Return the test data with each resource of the same id replaced.""" - replacements = {resource["id"]: resource for resource in resources} - missing = replacements.keys() - {resource["id"] for resource in data} - assert not missing, f"resource id(s) not present in the test data: {missing}" - return [replacements.get(resource["id"], resource) for resource in data] - - async def test_binary_sensors( hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType ) -> None: @@ -514,3 +513,57 @@ async def test_motion_aware_sensor_zone_not_reporting( ) await hass.async_block_till_done() assert hass.states.get(MOTION_AWARE_ENTITY_ID).state == "off" + + +@pytest.mark.parametrize( + ("group", "device_identifier"), + [ + pytest.param( + {"rid": TEST_ROOM_ID, "rtype": "room"}, + (DOMAIN, TEST_ROOM_ID), + id="room", + ), + pytest.param( + {"rid": BRIDGE_HOME_ID, "rtype": "bridge_home"}, + (DOMAIN, FAKE_BRIDGE["bridge_id"]), + id="whole_home", + ), + ], +) +async def test_motion_aware_sensor_device( + hass: HomeAssistant, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + group: dict[str, str], + device_identifier: tuple[str, str], +) -> None: + """Test the MotionAware sensor is attached to its room or zone, or to the bridge. + + A MotionAware zone that covers the whole home points at `bridge_home`, which + has no device of its own. + """ + motion_area_configuration = next( + resource + for resource in v2_resources_test_data + if resource["id"] == MOTION_AREA_CONFIGURATION_ID + ) + await mock_bridge_v2.api.load_test_data( + replace_resources( + v2_resources_test_data, [{**motion_area_configuration, "group": group}] + ) + ) + await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR) + + entity_id = entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, AREA_MOTION_SERVICE_IDS["security_area_motion"] + ) + assert entity_id is not None + assert hass.states.get(entity_id).state == "off" + + device = device_registry.async_get_device_by_identifier( + device_identifier, mock_bridge_v2.config_entry.entry_id + ) + assert device is not None + assert entity_registry.async_get(entity_id).device_id == device.id diff --git a/tests/components/hue/test_switch.py b/tests/components/hue/test_switch.py index 66e8e738944013..412c70fb111cc6 100644 --- a/tests/components/hue/test_switch.py +++ b/tests/components/hue/test_switch.py @@ -10,11 +10,12 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util.json import JsonArrayType -from .conftest import setup_platform +from .conftest import replace_resources, setup_platform from .const import ( FAKE_BEHAVIOR_INSTANCE, FAKE_BEHAVIOR_SCRIPT, FAKE_BINARY_SENSOR, + FAKE_BRIDGE, FAKE_DEVICE, FAKE_PRESENCE_MIMICKING_INSTANCE, FAKE_PRESENCE_MIMICKING_SCRIPT, @@ -22,6 +23,8 @@ ) TEST_ROOM_ID = "6ddc9066-7e7d-4a03-a773-c73937968296" +BRIDGE_HOME_ID = "a3fbc86a-bf4c-4c69-899d-d6eafc37e288" +MOTION_AREA_CONFIGURATION_ID = "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b" async def test_switch( @@ -59,26 +62,58 @@ async def test_switch( assert test_entity.attributes["device_class"] == "switch" +@pytest.mark.parametrize( + ("group", "device_identifier"), + [ + pytest.param( + {"rid": TEST_ROOM_ID, "rtype": "room"}, + (DOMAIN, TEST_ROOM_ID), + id="room", + ), + pytest.param( + {"rid": BRIDGE_HOME_ID, "rtype": "bridge_home"}, + (DOMAIN, FAKE_BRIDGE["bridge_id"]), + id="whole_home", + ), + ], +) async def test_motionaware_switch_device( hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + group: dict[str, str], + device_identifier: tuple[str, str], ) -> None: - """Test the MotionAware switch is attached to the zone device, not the bridge.""" - await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + """Test the MotionAware switch is attached to its room or zone, or to the bridge. + A MotionAware zone that covers the whole home points at `bridge_home`, which + has no device of its own. + """ + motion_area_configuration = next( + resource + for resource in v2_resources_test_data + if resource["id"] == MOTION_AREA_CONFIGURATION_ID + ) + await mock_bridge_v2.api.load_test_data( + replace_resources( + v2_resources_test_data, [{**motion_area_configuration, "group": group}] + ) + ) await setup_platform(hass, mock_bridge_v2, Platform.SWITCH) - entity_entry = entity_registry.async_get("switch.test_room_test_room_motionaware") - assert entity_entry is not None + entity_id = entity_registry.async_get_entity_id( + Platform.SWITCH, DOMAIN, MOTION_AREA_CONFIGURATION_ID + ) + assert entity_id is not None + assert hass.states.get(entity_id).state == "on" - zone_device = device_registry.async_get_device_by_identifier( - (DOMAIN, TEST_ROOM_ID), mock_bridge_v2.config_entry.entry_id + device = device_registry.async_get_device_by_identifier( + device_identifier, mock_bridge_v2.config_entry.entry_id ) - assert zone_device is not None - assert entity_entry.device_id == zone_device.id + assert device is not None + assert entity_registry.async_get(entity_id).device_id == device.id async def test_switch_turn_on_service( @@ -172,14 +207,13 @@ async def test_motionaware_switch_turn_on_off_service( assert mock_bridge_v2.mock_requests[0]["method"] == "put" assert ( mock_bridge_v2.mock_requests[0]["path"] - == "clip/v2/resource/motion_area_configuration/" - "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b" + == f"clip/v2/resource/motion_area_configuration/{MOTION_AREA_CONFIGURATION_ID}" ) assert mock_bridge_v2.mock_requests[0]["json"]["enabled"] is False # Now generate update event by emitting the json we've sent as incoming event event = { - "id": "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b", + "id": MOTION_AREA_CONFIGURATION_ID, "type": "motion_area_configuration", **mock_bridge_v2.mock_requests[0]["json"], } From 6b6a23298d23db1f6f219cf904d543c8ecf845f4 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 18 Sep 2026 15:39:17 +0200 Subject: [PATCH 07/26] Keep Brother counters and wear parts as diagnostic (#182591) --- homeassistant/components/brother/sensor.py | 24 ++++++++++ .../brother/snapshots/test_sensor.ambr | 44 +++++++++---------- 2 files changed, 46 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 2768d0922fbfc0..21e46444c8948a 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -51,24 +51,28 @@ class BrotherSensorEntityDescription(SensorEntityDescription): key="page_counter", translation_key="page_counter", state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.page_counter, ), BrotherSensorEntityDescription( key="bw_counter", translation_key="bw_pages", state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.bw_counter, ), BrotherSensorEntityDescription( key="color_counter", translation_key="color_pages", state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.color_counter, ), BrotherSensorEntityDescription( key="duplex_unit_pages_counter", translation_key="duplex_unit_page_counter", state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.duplex_unit_pages_counter, ), BrotherSensorEntityDescription( @@ -76,18 +80,21 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.drum_remaining_life, ), BrotherSensorEntityDescription( key="drum_remaining_pages", translation_key="drum_remaining_pages", state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.drum_remaining_pages, ), BrotherSensorEntityDescription( key="drum_counter", translation_key="drum_page_counter", state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.drum_counter, ), BrotherSensorEntityDescription( @@ -95,18 +102,21 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="black_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.black_drum_remaining_life, ), BrotherSensorEntityDescription( key="black_drum_remaining_pages", translation_key="black_drum_remaining_pages", state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.black_drum_remaining_pages, ), BrotherSensorEntityDescription( key="black_drum_counter", translation_key="black_drum_page_counter", state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.black_drum_counter, ), BrotherSensorEntityDescription( @@ -114,18 +124,21 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="cyan_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.cyan_drum_remaining_life, ), BrotherSensorEntityDescription( key="cyan_drum_remaining_pages", translation_key="cyan_drum_remaining_pages", state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.cyan_drum_remaining_pages, ), BrotherSensorEntityDescription( key="cyan_drum_counter", translation_key="cyan_drum_page_counter", state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.cyan_drum_counter, ), BrotherSensorEntityDescription( @@ -133,18 +146,21 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="magenta_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.magenta_drum_remaining_life, ), BrotherSensorEntityDescription( key="magenta_drum_remaining_pages", translation_key="magenta_drum_remaining_pages", state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.magenta_drum_remaining_pages, ), BrotherSensorEntityDescription( key="magenta_drum_counter", translation_key="magenta_drum_page_counter", state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.magenta_drum_counter, ), BrotherSensorEntityDescription( @@ -152,18 +168,21 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="yellow_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.yellow_drum_remaining_life, ), BrotherSensorEntityDescription( key="yellow_drum_remaining_pages", translation_key="yellow_drum_remaining_pages", state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.yellow_drum_remaining_pages, ), BrotherSensorEntityDescription( key="yellow_drum_counter", translation_key="yellow_drum_page_counter", state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.yellow_drum_counter, ), BrotherSensorEntityDescription( @@ -171,6 +190,7 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="belt_unit_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.belt_unit_remaining_life, ), BrotherSensorEntityDescription( @@ -178,6 +198,7 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="fuser_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.fuser_remaining_life, ), BrotherSensorEntityDescription( @@ -185,6 +206,7 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="laser_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.laser_remaining_life, ), BrotherSensorEntityDescription( @@ -192,6 +214,7 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="pf_kit_1_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.pf_kit_1_remaining_life, ), BrotherSensorEntityDescription( @@ -199,6 +222,7 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="pf_kit_mp_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.pf_kit_mp_remaining_life, ), BrotherSensorEntityDescription( diff --git a/tests/components/brother/snapshots/test_sensor.ambr b/tests/components/brother/snapshots/test_sensor.ambr index 64b8a6cdcaeca5..877b22fccaa677 100644 --- a/tests/components/brother/snapshots/test_sensor.ambr +++ b/tests/components/brother/snapshots/test_sensor.ambr @@ -14,7 +14,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.hl_l2340dw_b_w_pages', 'has_entity_name': True, 'hidden_by': None, @@ -68,7 +68,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.hl_l2340dw_belt_unit_remaining_lifetime', 'has_entity_name': True, 'hidden_by': None, @@ -122,7 +122,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.hl_l2340dw_black_drum_page_counter', 'has_entity_name': True, 'hidden_by': None, @@ -176,7 +176,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.hl_l2340dw_black_drum_remaining_lifetime', 'has_entity_name': True, 'hidden_by': None, @@ -230,7 +230,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.hl_l2340dw_black_drum_remaining_pages', 'has_entity_name': True, 'hidden_by': None, @@ -338,7 +338,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.hl_l2340dw_color_pages', 'has_entity_name': True, 'hidden_by': None, @@ -392,7 +392,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.hl_l2340dw_cyan_drum_page_counter', 'has_entity_name': True, 'hidden_by': None, @@ -446,7 +446,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.hl_l2340dw_cyan_drum_remaining_lifetime', 'has_entity_name': True, 'hidden_by': None, @@ -500,7 +500,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.hl_l2340dw_cyan_drum_remaining_pages', 'has_entity_name': True, 'hidden_by': None, @@ -608,7 +608,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.hl_l2340dw_drum_page_counter', 'has_entity_name': True, 'hidden_by': None, @@ -662,7 +662,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.hl_l2340dw_drum_remaining_lifetime', 'has_entity_name': True, 'hidden_by': None, @@ -716,7 +716,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.hl_l2340dw_drum_remaining_pages', 'has_entity_name': True, 'hidden_by': None, @@ -770,7 +770,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.hl_l2340dw_duplex_unit_page_counter', 'has_entity_name': True, 'hidden_by': None, @@ -824,7 +824,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.hl_l2340dw_fuser_remaining_lifetime', 'has_entity_name': True, 'hidden_by': None, @@ -878,7 +878,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.hl_l2340dw_magenta_drum_page_counter', 'has_entity_name': True, 'hidden_by': None, @@ -932,7 +932,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.hl_l2340dw_magenta_drum_remaining_lifetime', 'has_entity_name': True, 'hidden_by': None, @@ -986,7 +986,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.hl_l2340dw_magenta_drum_remaining_pages', 'has_entity_name': True, 'hidden_by': None, @@ -1094,7 +1094,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.hl_l2340dw_page_counter', 'has_entity_name': True, 'hidden_by': None, @@ -1148,7 +1148,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.hl_l2340dw_pf_kit_1_remaining_lifetime', 'has_entity_name': True, 'hidden_by': None, @@ -1303,7 +1303,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.hl_l2340dw_yellow_drum_page_counter', 'has_entity_name': True, 'hidden_by': None, @@ -1357,7 +1357,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.hl_l2340dw_yellow_drum_remaining_lifetime', 'has_entity_name': True, 'hidden_by': None, @@ -1411,7 +1411,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.hl_l2340dw_yellow_drum_remaining_pages', 'has_entity_name': True, 'hidden_by': None, From 032ee44650c35b69c7703a59467b94399961a241 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 18 Sep 2026 09:50:40 -0400 Subject: [PATCH 08/26] Use ToolResult in ovhcloud_ai_endpoints (#182550) Co-authored-by: Claude --- homeassistant/components/ovhcloud_ai_endpoints/entity.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ovhcloud_ai_endpoints/entity.py b/homeassistant/components/ovhcloud_ai_endpoints/entity.py index c8f7656d14afc8..7bdaf5948315c6 100644 --- a/homeassistant/components/ovhcloud_ai_endpoints/entity.py +++ b/homeassistant/components/ovhcloud_ai_endpoints/entity.py @@ -59,7 +59,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 From d7be305892e5efae01d163303fa4603d62fe1114 Mon Sep 17 00:00:00 2001 From: Yoav Mor Date: Fri, 18 Sep 2026 16:53:12 +0300 Subject: [PATCH 09/26] Bump besen to 0.4.6 (#182520) --- homeassistant/components/besen/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/besen/manifest.json b/homeassistant/components/besen/manifest.json index f7f2187dc8136d..6a631ae376a52c 100644 --- a/homeassistant/components/besen/manifest.json +++ b/homeassistant/components/besen/manifest.json @@ -14,5 +14,5 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["besen==0.4.5"] + "requirements": ["besen==0.4.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index fcfad60f3b858d..41a558ad2dc584 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -676,7 +676,7 @@ batinfo==0.4.2 beautifulsoup4==4.13.3 # homeassistant.components.besen -besen==0.4.5 +besen==0.4.6 # homeassistant.components.bitvis bitvis-protobuf==2.0.4 From 32413f4a7018ec58960bf8ac6c8d569bba2fd989 Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Fri, 18 Sep 2026 06:54:26 -0700 Subject: [PATCH 10/26] Bump pysnooz to 0.10.0 (#182225) --- homeassistant/components/snooz/__init__.py | 11 +++- homeassistant/components/snooz/config_flow.py | 44 +++++++------- homeassistant/components/snooz/fan.py | 4 +- homeassistant/components/snooz/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/snooz/__init__.py | 54 +++++++++-------- tests/components/snooz/test_fan.py | 60 +++++++++---------- tests/components/snooz/test_init.py | 9 +-- 8 files changed, 98 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/snooz/__init__.py b/homeassistant/components/snooz/__init__.py index d485723844f79c..2aada56a8c4d93 100644 --- a/homeassistant/components/snooz/__init__.py +++ b/homeassistant/components/snooz/__init__.py @@ -1,13 +1,15 @@ """The Snooz component.""" +from dataclasses import replace import logging +from pysnooz import parse_snooz_advertisement from pysnooz.device import SnoozDevice from homeassistant.components.bluetooth import ( BluetoothReachabilityIntent, async_address_reachability_diagnostics, - async_ble_device_from_address, + async_last_service_info, ) from homeassistant.const import CONF_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant @@ -25,7 +27,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SnoozConfigEntry) -> boo # transitions info logs are verbose. Only enable warnings logging.getLogger("transitions.core").setLevel(logging.WARNING) - if not (ble_device := async_ble_device_from_address(hass, address)): + if (service_info := async_last_service_info(hass, address)) is None or ( + advertisement := parse_snooz_advertisement(service_info) + ) is None: raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="device_not_found", @@ -39,7 +43,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SnoozConfigEntry) -> boo }, ) - device = SnoozDevice(ble_device, token) + ble_device = service_info.device + device = SnoozDevice(ble_device, replace(advertisement, password=token)) entry.runtime_data = SnoozConfigurationData(ble_device, device, entry.title) diff --git a/homeassistant/components/snooz/config_flow.py b/homeassistant/components/snooz/config_flow.py index ad3d8fb2718a71..a3443bc0b9e939 100644 --- a/homeassistant/components/snooz/config_flow.py +++ b/homeassistant/components/snooz/config_flow.py @@ -5,7 +5,11 @@ from typing import Any, override import probatio -from pysnooz.advertisement import SnoozAdvertisementData +from pysnooz import ( + SnoozAdvertisementData, + get_device_display_name, + parse_snooz_advertisement, +) from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( @@ -30,6 +34,11 @@ class DiscoveredSnooz: info: BluetoothServiceInfo device: SnoozAdvertisementData + @property + def display_name(self) -> str: + """Return the display name.""" + return get_device_display_name(self.info.name, self.info.address) + class SnoozConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Snooz.""" @@ -49,8 +58,7 @@ async def async_step_bluetooth( """Handle the bluetooth discovery step.""" await self.async_set_unique_id(discovery_info.address) self._abort_if_unique_id_configured() - device = SnoozAdvertisementData() - if not device.supported(discovery_info): + if (device := parse_snooz_advertisement(discovery_info)) is None: return self.async_abort(reason="not_supported") self._discovery = DiscoveredSnooz(discovery_info, device) return await self.async_step_bluetooth_confirm() @@ -68,8 +76,7 @@ async def async_step_bluetooth_confirm( return self._create_snooz_entry(self._discovery) self._set_confirm_only() - assert self._discovery.device.display_name - placeholders = {"name": self._discovery.device.display_name} + placeholders = {"name": self._discovery.display_name} self.context["title_placeholders"] = placeholders return self.async_show_form( step_id="bluetooth_confirm", description_placeholders=placeholders @@ -104,12 +111,9 @@ async def async_step_user( address = info.address if address in configured_addresses: continue - device = SnoozAdvertisementData() - if device.supported(info): - assert device.display_name - self._discovered_devices[device.display_name] = DiscoveredSnooz( - info, device - ) + if (device := parse_snooz_advertisement(info)) is not None: + discovered = DiscoveredSnooz(info, device) + self._discovered_devices[discovered.display_name] = discovered if not self._discovered_devices: return self.async_abort(reason="no_devices_found") @@ -121,10 +125,7 @@ async def async_step_user( # Name field is no longer allowed in config flow schemas # pylint: disable-next=home-assistant-config-flow-name-field probatio.Required(CONF_NAME): probatio.In( - [ - d.device.display_name - for d in self._discovered_devices.values() - ] + [d.display_name for d in self._discovered_devices.values()] ) } ), @@ -179,24 +180,27 @@ async def async_step_pairing_timeout( return self.async_show_form(step_id="pairing_timeout") def _create_snooz_entry(self, discovery: DiscoveredSnooz) -> ConfigFlowResult: - assert discovery.device.display_name + assert discovery.device.password is not None return self.async_create_entry( - title=discovery.device.display_name, + title=discovery.display_name, data={ CONF_ADDRESS: discovery.info.address, - CONF_TOKEN: discovery.device.pairing_token, + CONF_TOKEN: discovery.device.password, }, ) async def _async_wait_for_pairing_mode(self) -> None: """Process advertisements until pairing mode is detected.""" assert self._discovery - device = self._discovery.device def is_device_in_pairing_mode( service_info: BluetoothServiceInfo, ) -> bool: - return device.supported(service_info) and device.is_pairing + device = parse_snooz_advertisement(service_info) + if device is None or not device.is_pairing: + return False + self._discovery = DiscoveredSnooz(service_info, device) + return True await async_process_advertisements( self.hass, diff --git a/homeassistant/components/snooz/fan.py b/homeassistant/components/snooz/fan.py index afcea07795743b..7da93242ea5dda 100644 --- a/homeassistant/components/snooz/fan.py +++ b/homeassistant/components/snooz/fan.py @@ -5,7 +5,7 @@ from typing import Any, override import probatio -from pysnooz.api import UnknownSnoozState +from pysnooz import UnknownSnoozState from pysnooz.commands import ( SnoozCommandData, SnoozCommandResultStatus, @@ -139,7 +139,7 @@ def is_on(self) -> bool | None: @override def assumed_state(self) -> bool: """Return True if unable to access real state of the entity.""" - return not self._device.is_connected or self._device.state is UnknownSnoozState + return not self._device.is_connected or self._device.state == UnknownSnoozState @override async def async_turn_on( diff --git a/homeassistant/components/snooz/manifest.json b/homeassistant/components/snooz/manifest.json index a0a10f23a6f4d9..5bd9d72eb00f62 100644 --- a/homeassistant/components/snooz/manifest.json +++ b/homeassistant/components/snooz/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/snooz", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pysnooz==0.8.6"] + "requirements": ["pysnooz==0.10.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 41a558ad2dc584..e51dbc87caaaca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2663,7 +2663,7 @@ pysmlight==0.5.3 pysnmp==7.1.27 # homeassistant.components.snooz -pysnooz==0.8.6 +pysnooz==0.10.0 # homeassistant.components.soma pysoma==0.0.12 diff --git a/tests/components/snooz/__init__.py b/tests/components/snooz/__init__.py index 2ea719665c0a7d..a74b8c4c5f9b19 100644 --- a/tests/components/snooz/__init__.py +++ b/tests/components/snooz/__init__.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from unittest.mock import patch -from pysnooz.commands import SnoozCommandData +from pysnooz import SnoozDeviceState, parse_snooz_advertisement, turn_off from pysnooz.device import DisconnectionReason, SnoozConnectionStatus from pysnooz.testing import MockSnoozDevice as ParentMockSnoozDevice @@ -13,12 +13,15 @@ from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from tests.common import MockConfigEntry -from tests.components.bluetooth import generate_ble_device +from tests.components.bluetooth import ( + generate_ble_device, + inject_bluetooth_service_info, +) TEST_ADDRESS = "00:00:00:00:AB:CD" TEST_SNOOZ_LOCAL_NAME = "Snooz-ABCD" TEST_SNOOZ_DISPLAY_NAME = "Snooz ABCD" -TEST_PAIRING_TOKEN = "deadbeef" +TEST_PAIRING_TOKEN = "deadbeefdeadbeef" NOT_SNOOZ_SERVICE_INFO = BluetoothServiceInfo( name="Definitely not snooz", @@ -34,7 +37,7 @@ name=TEST_SNOOZ_LOCAL_NAME, address=TEST_ADDRESS, rssi=-63, - manufacturer_data={65552: bytes([4]) + bytes.fromhex(TEST_PAIRING_TOKEN)}, + manufacturer_data={65552: bytes([5]) + bytes.fromhex(TEST_PAIRING_TOKEN)}, service_uuids=[ "80c37f00-cc16-11e4-8830-0800200c9a66", "90759319-1668-44da-9ef3-492d593bd1e5", @@ -73,7 +76,7 @@ class MockSnoozDevice(ParentMockSnoozDevice): async def async_disconnect(self) -> None: """Disconnect from the device.""" - self._is_manually_disconnecting = True + self._expected_disconnect = True try: self._cancel_current_command() if ( @@ -92,21 +95,23 @@ async def async_disconnect(self) -> None: self._machine.device_disconnected(reason=DisconnectionReason.USER) finally: - self._is_manually_disconnecting = False + self._expected_disconnect = False async def create_mock_snooz( connected: bool = True, - initial_state: SnoozCommandData = SnoozCommandData(on=False, volume=0), + initial_state: SnoozDeviceState = SnoozDeviceState(on=False, volume=0), ) -> MockSnoozDevice: """Create a mock device.""" - ble_device = SNOOZ_SERVICE_INFO_NOT_PAIRING - device = MockSnoozDevice(ble_device, initial_state=initial_state) + ble_device = generate_ble_device(TEST_ADDRESS, TEST_SNOOZ_LOCAL_NAME) + advertisement = parse_snooz_advertisement(SNOOZ_SERVICE_INFO_PAIRING) + assert advertisement is not None + device = MockSnoozDevice(ble_device, advertisement, initial_state=initial_state) # execute a command to initiate the connection if connected is True: - await device.async_execute_command(initial_state) + await device.async_execute_command(turn_off()) return device @@ -116,21 +121,20 @@ async def create_mock_snooz_config_entry( ) -> MockConfigEntry: """Create a mock config entry.""" - with ( - patch("homeassistant.components.snooz.SnoozDevice", return_value=device), - patch( - "homeassistant.components.snooz.async_ble_device_from_address", - return_value=generate_ble_device(device.address, device.name), - ), - ): - entry = MockConfigEntry( - domain=DOMAIN, - unique_id=TEST_ADDRESS, - data={CONF_ADDRESS: TEST_ADDRESS, CONF_TOKEN: TEST_PAIRING_TOKEN}, - ) - entry.add_to_hass(hass) - + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_ADDRESS, + data={CONF_ADDRESS: TEST_ADDRESS, CONF_TOKEN: TEST_PAIRING_TOKEN}, + ) + entry.add_to_hass(hass) + inject_bluetooth_service_info(hass, SNOOZ_SERVICE_INFO_NOT_PAIRING) + + with patch( + "homeassistant.components.snooz.SnoozDevice", return_value=device + ) as mock_snooz_device: assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - return entry + assert mock_snooz_device.call_args.args[1].password == TEST_PAIRING_TOKEN + + return entry diff --git a/tests/components/snooz/test_fan.py b/tests/components/snooz/test_fan.py index a31b83fb9ccc29..b581cad9c9a666 100644 --- a/tests/components/snooz/test_fan.py +++ b/tests/components/snooz/test_fan.py @@ -1,9 +1,9 @@ """Test Snooz fan entity.""" from datetime import timedelta -from unittest.mock import Mock, patch +from unittest.mock import Mock, PropertyMock, patch -from pysnooz.api import SnoozDeviceState, UnknownSnoozState +from pysnooz import SnoozDeviceState, UnknownSnoozState from pysnooz.commands import SnoozCommandResult, SnoozCommandResultStatus from pysnooz.testing import MockSnoozDevice import pytest @@ -30,8 +30,6 @@ from . import SnoozFixture, create_mock_snooz, create_mock_snooz_config_entry -from tests.components.bluetooth import generate_ble_device - async def test_turn_on(hass: HomeAssistant, snooz_fan_entity_id: str) -> None: """Test turning on the device.""" @@ -200,13 +198,7 @@ async def test_restore_state( assert state.state == STATE_UNAVAILABLE # reload entry - with ( - patch("homeassistant.components.snooz.SnoozDevice", return_value=device), - patch( - "homeassistant.components.snooz.async_ble_device_from_address", - return_value=generate_ble_device(device.address, device.name), - ), - ): + with patch("homeassistant.components.snooz.SnoozDevice", return_value=device): await hass.config_entries.async_setup(entry.entry_id) # should match last known state @@ -232,13 +224,7 @@ async def test_restore_unknown_state( assert state.state == STATE_UNAVAILABLE # reload entry - with ( - patch("homeassistant.components.snooz.SnoozDevice", return_value=device), - patch( - "homeassistant.components.snooz.async_ble_device_from_address", - return_value=generate_ble_device(device.address, device.name), - ), - ): + with patch("homeassistant.components.snooz.SnoozDevice", return_value=device): await hass.config_entries.async_setup(entry.entry_id) # should match last known state @@ -257,14 +243,19 @@ async def test_command_results( mock_execute.return_value = SnoozCommandResult( SnoozCommandResultStatus.SUCCESSFUL, timedelta() ) - mock_connected_snooz.device.state = SnoozDeviceState(on=True, volume=56) - await hass.services.async_call( - fan.DOMAIN, - fan.SERVICE_TURN_ON, - {ATTR_ENTITY_ID: [snooz_fan_entity_id]}, - blocking=True, - ) + with patch.object( + MockSnoozDevice, + "state", + new_callable=PropertyMock, + return_value=SnoozDeviceState(on=True, volume=56), + ): + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [snooz_fan_entity_id]}, + blocking=True, + ) state = hass.states.get(snooz_fan_entity_id) assert state.state == STATE_ON @@ -273,14 +264,19 @@ async def test_command_results( mock_execute.return_value = SnoozCommandResult( SnoozCommandResultStatus.CANCELLED, timedelta() ) - mock_connected_snooz.device.state = SnoozDeviceState(on=False, volume=15) - await hass.services.async_call( - fan.DOMAIN, - fan.SERVICE_TURN_ON, - {ATTR_ENTITY_ID: [snooz_fan_entity_id]}, - blocking=True, - ) + with patch.object( + MockSnoozDevice, + "state", + new_callable=PropertyMock, + return_value=SnoozDeviceState(on=False, volume=15), + ): + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [snooz_fan_entity_id]}, + blocking=True, + ) # the device state shouldn't be written when cancelled state = hass.states.get(snooz_fan_entity_id) diff --git a/tests/components/snooz/test_init.py b/tests/components/snooz/test_init.py index 4a03dc22cd4877..050096d304a610 100644 --- a/tests/components/snooz/test_init.py +++ b/tests/components/snooz/test_init.py @@ -16,11 +16,11 @@ from tests.common import MockConfigEntry -async def test_setup_retries_when_device_not_found( +async def test_setup_retries_when_service_info_not_found( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, ) -> None: - """Test setup is retried with a diagnostic reason when the device is missing.""" + """Test setup is retried when Bluetooth service info is missing.""" entry = MockConfigEntry( domain=DOMAIN, unique_id=TEST_ADDRESS, @@ -30,9 +30,9 @@ async def test_setup_retries_when_device_not_found( with ( patch( - "homeassistant.components.snooz.async_ble_device_from_address", + "homeassistant.components.snooz.async_last_service_info", return_value=None, - ), + ) as mock_last_service_info, patch( "homeassistant.components.snooz.async_address_reachability_diagnostics", return_value="mock reachability reason", @@ -41,6 +41,7 @@ async def test_setup_retries_when_device_not_found( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + mock_last_service_info.assert_called_once_with(hass, TEST_ADDRESS) assert entry.state is ConfigEntryState.SETUP_RETRY assert ( f"Could not find Snooz with address {TEST_ADDRESS}: mock reachability reason" From a8372415e4d370684bc78d1b2a09aa19239f0f10 Mon Sep 17 00:00:00 2001 From: "Dr.Blank" Date: Fri, 18 Sep 2026 19:45:22 +0530 Subject: [PATCH 11/26] Add virtual integration Onida Infrared (#182575) --- homeassistant/components/onida_infrared/__init__.py | 1 + homeassistant/components/onida_infrared/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/onida_infrared/__init__.py create mode 100644 homeassistant/components/onida_infrared/manifest.json diff --git a/homeassistant/components/onida_infrared/__init__.py b/homeassistant/components/onida_infrared/__init__.py new file mode 100644 index 00000000000000..e2d82d039ae2b9 --- /dev/null +++ b/homeassistant/components/onida_infrared/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Onida Infrared.""" diff --git a/homeassistant/components/onida_infrared/manifest.json b/homeassistant/components/onida_infrared/manifest.json new file mode 100644 index 00000000000000..5dff3db822d097 --- /dev/null +++ b/homeassistant/components/onida_infrared/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "onida_infrared", + "name": "Onida Infrared", + "integration_type": "virtual", + "supported_by": "gree_infrared" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b0bd1e42431d52..62262e0f421abb 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5225,6 +5225,11 @@ "config_flow": true, "iot_class": "local_polling" }, + "onida_infrared": { + "name": "Onida Infrared", + "integration_type": "virtual", + "supported_by": "gree_infrared" + }, "onkyo": { "name": "Onkyo", "integration_type": "device", From f83552c100a4182f60852d8d90d49b8859bc9bb2 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 18 Sep 2026 16:19:34 +0200 Subject: [PATCH 12/26] Add door warning to Midea (#182579) --- .../components/midea/binary_sensor.py | 6 +++ homeassistant/components/midea/strings.json | 3 ++ .../midea/snapshots/test_binary_sensor.ambr | 51 +++++++++++++++++++ tests/components/midea/test_binary_sensor.py | 11 ++++ 4 files changed, 71 insertions(+) diff --git a/homeassistant/components/midea/binary_sensor.py b/homeassistant/components/midea/binary_sensor.py index 16978cce52ca44..3c1e715c5f80c8 100644 --- a/homeassistant/components/midea/binary_sensor.py +++ b/homeassistant/components/midea/binary_sensor.py @@ -24,6 +24,12 @@ key="door", device_class=BinarySensorDeviceClass.OPENING, ), + BinarySensorEntityDescription( + key="door_warn", + translation_key="door_warn", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), BinarySensorEntityDescription( key="rinse_aid", translation_key="rinse_aid", diff --git a/homeassistant/components/midea/strings.json b/homeassistant/components/midea/strings.json index 4dd19acf76bcce..8ea20f88c3bb72 100644 --- a/homeassistant/components/midea/strings.json +++ b/homeassistant/components/midea/strings.json @@ -118,6 +118,9 @@ "arofene_link": { "name": "Methanal" }, + "door_warn": { + "name": "Door warning" + }, "fall_asleep_status": { "name": "Fall asleep" }, diff --git a/tests/components/midea/snapshots/test_binary_sensor.ambr b/tests/components/midea/snapshots/test_binary_sensor.ambr index 5d4b49329e9232..af38b4a898a34f 100644 --- a/tests/components/midea/snapshots/test_binary_sensor.ambr +++ b/tests/components/midea/snapshots/test_binary_sensor.ambr @@ -101,6 +101,57 @@ 'state': 'on', }) # --- +# name: test_all_entities[dc][binary_sensor.clothes_dryer_door_warning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.clothes_dryer_door_warning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Door warning', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door warning', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'door_warn', + 'unique_id': '12345678_door_warn', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[dc][binary_sensor.clothes_dryer_door_warning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : 'Clothes Dryer Door warning', + }), + 'context': , + 'entity_id': 'binary_sensor.clothes_dryer_door_warning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[e1][binary_sensor.dishwasher_opening-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/midea/test_binary_sensor.py b/tests/components/midea/test_binary_sensor.py index 62cd1b5e26860a..946fa571599c77 100644 --- a/tests/components/midea/test_binary_sensor.py +++ b/tests/components/midea/test_binary_sensor.py @@ -5,6 +5,7 @@ from midealocal.const import DeviceType from midealocal.devices.ac import DeviceAttributes as ACAttributes +from midealocal.devices.dc import DeviceAttributes as DCAttributes from midealocal.devices.e1 import DeviceAttributes as E1Attributes from midealocal.devices.x26 import DeviceAttributes as X26Attributes import pytest @@ -62,10 +63,20 @@ def _x26_device() -> DummyDevice: ) +def _dc_device() -> DummyDevice: + return DummyDevice( + DeviceType.DC, + attributes={ + DCAttributes.door_warn: True, + }, + ) + + @pytest.mark.parametrize( "device", [ pytest.param(_ac_device(), id="ac"), + pytest.param(_dc_device(), id="dc"), pytest.param(_e1_device(), id="e1"), pytest.param(_x26_device(), id="x26"), ], From 7d29177a46a050bd3dc0ffbeec341f531c4c9de9 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 18 Sep 2026 16:21:35 +0200 Subject: [PATCH 13/26] Add reconfigure flow to Mikrotik (#181763) --- .../components/mikrotik/config_flow.py | 48 ++++++++-- .../components/mikrotik/quality_scale.yaml | 2 +- .../components/mikrotik/strings.json | 20 +++- tests/components/mikrotik/test_config_flow.py | 96 +++++++++++++++++++ 4 files changed, 156 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/mikrotik/config_flow.py b/homeassistant/components/mikrotik/config_flow.py index 64dd8c077e8fad..99e129111bfbba 100644 --- a/homeassistant/components/mikrotik/config_flow.py +++ b/homeassistant/components/mikrotik/config_flow.py @@ -27,6 +27,16 @@ from .coordinator import MikrotikConfigEntry, get_api from .errors import CannotConnect, LoginError +DATA_SCHEMA = probatio.Schema( + { + probatio.Required(CONF_HOST): str, + probatio.Required(CONF_USERNAME): str, + probatio.Required(CONF_PASSWORD): str, + probatio.Optional(CONF_PORT, default=DEFAULT_API_PORT): int, + probatio.Optional(CONF_VERIFY_SSL, default=False): bool, + } +) + class MikrotikFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a Mikrotik config flow.""" @@ -65,14 +75,36 @@ async def async_step_user( ) return self.async_show_form( step_id="user", - data_schema=probatio.Schema( - { - probatio.Required(CONF_HOST): str, - probatio.Required(CONF_USERNAME): str, - probatio.Required(CONF_PASSWORD): str, - probatio.Optional(CONF_PORT, default=DEFAULT_API_PORT): int, - probatio.Optional(CONF_VERIFY_SSL, default=False): bool, - } + data_schema=DATA_SCHEMA, + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors = {} + reconfigure_entry = self._get_reconfigure_entry() + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + + try: + await self.hass.async_add_executor_job(get_api, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except LoginError: + errors[CONF_USERNAME] = "invalid_auth" + errors[CONF_PASSWORD] = "invalid_auth" + + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, data_updates=user_input + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + DATA_SCHEMA, reconfigure_entry.data ), errors=errors, ) diff --git a/homeassistant/components/mikrotik/quality_scale.yaml b/homeassistant/components/mikrotik/quality_scale.yaml index adca560d3af10b..898c0f804d04c2 100644 --- a/homeassistant/components/mikrotik/quality_scale.yaml +++ b/homeassistant/components/mikrotik/quality_scale.yaml @@ -66,7 +66,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: no known use cases for repair issues or flows, yet diff --git a/homeassistant/components/mikrotik/strings.json b/homeassistant/components/mikrotik/strings.json index a1a27d4ae6d1db..2fdf0a7ed7346e 100644 --- a/homeassistant/components/mikrotik/strings.json +++ b/homeassistant/components/mikrotik/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -19,6 +20,23 @@ "description": "The password for {username} is invalid.", "title": "[%key:common::config_flow::title::reauth%]" }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::ssl%]" + }, + "data_description": { + "host": "[%key:component::mikrotik::config::step::user::data_description::host%]", + "password": "[%key:component::mikrotik::config::step::user::data_description::password%]", + "port": "[%key:component::mikrotik::config::step::user::data_description::port%]", + "username": "[%key:component::mikrotik::config::step::user::data_description::username%]", + "verify_ssl": "[%key:component::mikrotik::config::step::user::data_description::verify_ssl%]" + }, + "title": "[%key:component::mikrotik::config::step::user::title%]" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/tests/components/mikrotik/test_config_flow.py b/tests/components/mikrotik/test_config_flow.py index 605f6496b6c173..d10e878969fc3e 100644 --- a/tests/components/mikrotik/test_config_flow.py +++ b/tests/components/mikrotik/test_config_flow.py @@ -1,5 +1,7 @@ """Test Mikrotik setup process.""" +from unittest.mock import patch + from librouteros.exceptions import ConnectionClosed, TrapError import pytest @@ -235,3 +237,97 @@ async def test_reauth_failed_conn_error( assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + + +RECONFIGURE_INPUT = { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + CONF_PORT: 8729, + CONF_VERIFY_SSL: True, +} + + +async def test_reconfigure_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntryFactory, +) -> None: + """Test reconfiguring the integration updates the config entry.""" + entry = mock_config_entry(data=DEMO_USER_INPUT) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=RECONFIGURE_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == RECONFIGURE_INPUT + + +async def test_reconfigure_host_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntryFactory, +) -> None: + """Test reconfigure aborts when the new host belongs to another entry.""" + entry = mock_config_entry(data=DEMO_USER_INPUT) + entry.add_to_hass(hass) + other_entry = mock_config_entry(data={**DEMO_USER_INPUT, CONF_HOST: "1.1.1.1"}) + other_entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=RECONFIGURE_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert entry.data == DEMO_USER_INPUT + + +@pytest.mark.parametrize( + ("side_effect", "expected_errors"), + [ + pytest.param(CONN_ERROR, {"base": "cannot_connect"}, id="cannot_connect"), + pytest.param( + AUTH_ERROR, + {CONF_USERNAME: "invalid_auth", CONF_PASSWORD: "invalid_auth"}, + id="invalid_auth", + ), + ], +) +async def test_reconfigure_error_recovery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntryFactory, + side_effect: Exception, + expected_errors: dict[str, str], +) -> None: + """Test reconfigure shows an error and then recovers on valid input.""" + entry = mock_config_entry(data=DEMO_USER_INPUT) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + with patch("librouteros.connect", side_effect=side_effect): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=RECONFIGURE_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == expected_errors + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=RECONFIGURE_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == RECONFIGURE_INPUT From b29d2abbb892dca25ef5c3169d77b1319b4a802c Mon Sep 17 00:00:00 2001 From: Chris <53621277+smitec11@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:47:38 +0200 Subject: [PATCH 14/26] Raise an error when the Kodi notification fails (#182592) Co-authored-by: Claude Opus 5 --- homeassistant/components/kodi/notify.py | 11 +++-- homeassistant/components/kodi/strings.json | 9 +++- tests/components/kodi/test_notify.py | 53 ++++++++++++++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 tests/components/kodi/test_notify.py diff --git a/homeassistant/components/kodi/notify.py b/homeassistant/components/kodi/notify.py index 4716cc97e9f862..57095197a70d05 100644 --- a/homeassistant/components/kodi/notify.py +++ b/homeassistant/components/kodi/notify.py @@ -23,10 +23,13 @@ CONF_USERNAME, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) DEFAULT_PORT = 8080 @@ -103,6 +106,8 @@ async def async_send_message(self, message: str = "", **kwargs: Any) -> None: title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) await self._server.GUI.ShowNotification(title, message, icon, displaytime) - # pylint: disable-next=home-assistant-action-swallowed-exception - except jsonrpc_async.TransportError: - _LOGGER.warning("Unable to fetch Kodi data. Is Kodi online?") + except jsonrpc_async.TransportError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="notify_failed", + ) from err diff --git a/homeassistant/components/kodi/strings.json b/homeassistant/components/kodi/strings.json index 20b90cd8e99776..d8928245cdeb61 100644 --- a/homeassistant/components/kodi/strings.json +++ b/homeassistant/components/kodi/strings.json @@ -54,11 +54,18 @@ "media_player": { "media_player": { "state_attributes": { - "dynamic_range": { "name": "Dynamic range" } + "dynamic_range": { + "name": "Dynamic range" + } } } } }, + "exceptions": { + "notify_failed": { + "message": "Unable to send the notification to Kodi. Is Kodi online?" + } + }, "services": { "add_to_playlist": { "description": "Adds music to the default playlist (i.e. playlistid=0).", diff --git a/tests/components/kodi/test_notify.py b/tests/components/kodi/test_notify.py new file mode 100644 index 00000000000000..fedd96be52daed --- /dev/null +++ b/tests/components/kodi/test_notify.py @@ -0,0 +1,53 @@ +"""Tests for the Kodi notify platform.""" + +from unittest.mock import AsyncMock, patch + +import jsonrpc_async +import pytest + +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component + +NOTIFY_CONFIG = { + NOTIFY_DOMAIN: { + "platform": "kodi", + "name": "kodi", + "host": "1.1.1.1", + "port": 8080, + } +} + + +async def setup_notify(hass: HomeAssistant, server: AsyncMock) -> None: + """Set up the Kodi notify platform with a mocked server.""" + with patch("jsonrpc_async.Server", return_value=server): + assert await async_setup_component(hass, NOTIFY_DOMAIN, NOTIFY_CONFIG) + await hass.async_block_till_done() + + +async def test_send_message(hass: HomeAssistant) -> None: + """Test sending a notification to Kodi.""" + server = AsyncMock() + await setup_notify(hass, server) + + await hass.services.async_call( + NOTIFY_DOMAIN, "kodi", {"message": "Hello", "title": "Test"}, blocking=True + ) + + server.GUI.ShowNotification.assert_called_once_with("Test", "Hello", "info", 10000) + + +async def test_send_message_transport_error(hass: HomeAssistant) -> None: + """Test an unreachable Kodi raises an error instead of only logging.""" + server = AsyncMock() + server.GUI.ShowNotification.side_effect = jsonrpc_async.TransportError( + "Unable to connect" + ) + await setup_notify(hass, server) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NOTIFY_DOMAIN, "kodi", {"message": "Hello"}, blocking=True + ) From 69c2215121be6bf7f6819235954f39ae91be511d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 18 Sep 2026 16:48:52 +0200 Subject: [PATCH 15/26] Import probatio directly instead of aliasing it as vol (#182595) Co-authored-by: Claude Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/de_dietrich/config_flow.py | 14 +++++++------- pyproject.toml | 4 ++++ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/de_dietrich/config_flow.py b/homeassistant/components/de_dietrich/config_flow.py index 707c52f299c77f..71a5728f687e8d 100644 --- a/homeassistant/components/de_dietrich/config_flow.py +++ b/homeassistant/components/de_dietrich/config_flow.py @@ -5,7 +5,7 @@ import diematic_modbus from modbus_connection import ModbusError, ModbusTcpParams -import probatio as vol +import probatio from homeassistant.components.modbus import async_get_temporary_unit from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -31,20 +31,20 @@ _LOGGER = logging.getLogger(__name__) -STEP_USER_DATA_SCHEMA = vol.Schema( +STEP_USER_DATA_SCHEMA = probatio.Schema( { - vol.Required(CONF_HOST): TextSelector(), - vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.All( + probatio.Required(CONF_HOST): TextSelector(), + probatio.Required(CONF_PORT, default=DEFAULT_PORT): probatio.All( NumberSelector( NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=1, max=65535) ), - vol.Coerce(int), + probatio.Coerce(int), ), - vol.Required(CONF_UNIT_ID, default=DEFAULT_UNIT_ID): vol.All( + probatio.Required(CONF_UNIT_ID, default=DEFAULT_UNIT_ID): probatio.All( NumberSelector( NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=1, max=247) ), - vol.Coerce(int), + probatio.Coerce(int), ), } ) diff --git a/pyproject.toml b/pyproject.toml index 58bd0f6e2739ed..e27f93d1c10382 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -662,6 +662,7 @@ select = [ "INP", # flake8-no-pep420 "ISC", # flake8-implicit-str-concat "ICN001", # import concentions; {name} should be imported as {asname} + "ICN002", # import conventions; {name} should not be imported as {asname} "LOG", # flake8-logging "N804", # First argument of a class method should be named cls "N805", # First argument of a method should be named self @@ -840,6 +841,9 @@ ignore = [ "homeassistant.util.uuid" = "uuid_util" "homeassistant.util.yaml" = "yaml_util" +[tool.ruff.lint.flake8-import-conventions.banned-aliases] +"probatio" = ["vol"] + [tool.ruff.lint.flake8-pytest-style] fixture-parentheses = false mark-parentheses = false From ae3e6c8ce2df49ea0dbd773b5ca03eec82de859a Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:53:55 +0200 Subject: [PATCH 16/26] Enumerate UniFi Protect entities from the public API and add switches to the API-key-only mode (#181169) --- .../components/unifiprotect/binary_sensor.py | 53 +-- .../components/unifiprotect/camera.py | 28 +- .../components/unifiprotect/const.py | 1 + homeassistant/components/unifiprotect/data.py | 81 +++- .../components/unifiprotect/entity.py | 211 +++++++-- .../components/unifiprotect/light.py | 23 +- .../components/unifiprotect/number.py | 12 +- .../components/unifiprotect/select.py | 15 +- .../components/unifiprotect/siren.py | 12 + .../components/unifiprotect/strings.json | 3 + .../components/unifiprotect/switch.py | 143 +++--- homeassistant/components/unifiprotect/text.py | 2 +- tests/components/unifiprotect/conftest.py | 80 +--- .../unifiprotect/test_alarm_control_panel.py | 17 +- tests/components/unifiprotect/test_fob.py | 53 +++ tests/components/unifiprotect/test_number.py | 59 ++- tests/components/unifiprotect/test_relay.py | 130 +++++- tests/components/unifiprotect/test_select.py | 28 +- tests/components/unifiprotect/test_siren.py | 52 ++- tests/components/unifiprotect/test_switch.py | 418 ++++++++++++++++-- tests/components/unifiprotect/utils.py | 136 ++++-- 21 files changed, 1174 insertions(+), 383 deletions(-) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 88ae7da658cc3f..d6891db8aac218 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -12,6 +12,7 @@ MountType, ProtectAdoptableDeviceModel, Sensor, + SmartDetectObjectType, ) from uiprotect.data.nvr import UOSDisk from uiprotect.data.public_devices import ( @@ -181,7 +182,7 @@ class ProtectBinaryEventEntityDescription( key="smart_person", translation_key="detections_person", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_person", + ufp_capability=SmartDetectObjectType.PERSON, ufp_value="is_person_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -189,7 +190,7 @@ class ProtectBinaryEventEntityDescription( key="smart_vehicle", translation_key="detections_vehicle", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_vehicle", + ufp_capability=SmartDetectObjectType.VEHICLE, ufp_value="is_vehicle_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -197,7 +198,7 @@ class ProtectBinaryEventEntityDescription( key="smart_animal", translation_key="detections_animal", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_animal", + ufp_capability=SmartDetectObjectType.ANIMAL, ufp_value="is_animal_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -205,7 +206,7 @@ class ProtectBinaryEventEntityDescription( key="smart_package", translation_key="detections_package", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_package", + ufp_capability=SmartDetectObjectType.PACKAGE, ufp_value="is_package_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -213,7 +214,7 @@ class ProtectBinaryEventEntityDescription( key="smart_licenseplate", translation_key="detections_license_plate", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_license_plate", + ufp_capability=SmartDetectObjectType.LICENSE_PLATE, ufp_value="is_license_plate_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -221,7 +222,7 @@ class ProtectBinaryEventEntityDescription( key="smart_smoke", translation_key="detections_smoke", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_smoke", + ufp_capability=SmartDetectObjectType.SMOKE, ufp_value="is_smoke_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -229,7 +230,7 @@ class ProtectBinaryEventEntityDescription( key="smart_cmonx", translation_key="detections_co_alarm", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_co", + ufp_capability=SmartDetectObjectType.CMONX, ufp_value="is_co_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -237,7 +238,7 @@ class ProtectBinaryEventEntityDescription( key="smart_siren", translation_key="detections_siren", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_siren", + ufp_capability=SmartDetectObjectType.SIREN, ufp_value="is_siren_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -245,7 +246,7 @@ class ProtectBinaryEventEntityDescription( key="smart_baby_cry", translation_key="detections_baby_cry", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_baby_cry", + ufp_capability=SmartDetectObjectType.BABY_CRY, ufp_value="is_baby_cry_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -253,7 +254,7 @@ class ProtectBinaryEventEntityDescription( key="smart_speak", translation_key="detections_speaking", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_speaking", + ufp_capability=SmartDetectObjectType.SPEAK, ufp_value="is_speaking_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -261,7 +262,7 @@ class ProtectBinaryEventEntityDescription( key="smart_bark", translation_key="detections_barking", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_bark", + ufp_capability=SmartDetectObjectType.BARK, ufp_value="is_bark_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -269,7 +270,7 @@ class ProtectBinaryEventEntityDescription( key="smart_car_alarm", translation_key="detections_car_alarm", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_car_alarm", + ufp_capability=SmartDetectObjectType.BURGLAR, ufp_value="is_car_alarm_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -277,7 +278,7 @@ class ProtectBinaryEventEntityDescription( key="smart_car_horn", translation_key="detections_car_horn", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_car_horn", + ufp_capability=SmartDetectObjectType.CAR_HORN, ufp_value="is_car_horn_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -285,7 +286,7 @@ class ProtectBinaryEventEntityDescription( key="smart_glass_break", translation_key="detections_glass_break", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_glass_break", + ufp_capability=SmartDetectObjectType.GLASS_BREAK, ufp_value="is_glass_break_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -316,7 +317,7 @@ class ProtectBinaryEventEntityDescription( ProtectBinaryEntityDescription( key="smart_obj_person", translation_key="person_detected", - ufp_required_field="can_detect_person", + ufp_capability=SmartDetectObjectType.PERSON, ufp_public_value="is_person_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_person_detection_on"), @@ -324,7 +325,7 @@ class ProtectBinaryEventEntityDescription( ProtectBinaryEntityDescription( key="smart_obj_vehicle", translation_key="vehicle_detected", - ufp_required_field="can_detect_vehicle", + ufp_capability=SmartDetectObjectType.VEHICLE, ufp_public_value="is_vehicle_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_vehicle_detection_on"), @@ -332,7 +333,7 @@ class ProtectBinaryEventEntityDescription( ProtectBinaryEntityDescription( key="smart_obj_animal", translation_key="animal_detected", - ufp_required_field="can_detect_animal", + ufp_capability=SmartDetectObjectType.ANIMAL, ufp_public_value="is_animal_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_animal_detection_on"), @@ -348,7 +349,7 @@ class ProtectBinaryEventEntityDescription( ProtectBinaryEntityDescription( key="smart_audio_smoke", translation_key="smoke_alarm_detected", - ufp_required_field="can_detect_smoke", + ufp_capability=SmartDetectObjectType.SMOKE, ufp_public_value="is_smoke_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_smoke_detection_on"), @@ -357,7 +358,7 @@ class ProtectBinaryEventEntityDescription( key="smart_audio_cmonx", translation_key="co_alarm_detected", device_class=BinarySensorDeviceClass.CO, - ufp_required_field="can_detect_co", + ufp_capability=SmartDetectObjectType.CMONX, ufp_public_value="is_cmonx_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_co_detection_on"), @@ -365,7 +366,7 @@ class ProtectBinaryEventEntityDescription( ProtectBinaryEntityDescription( key="smart_audio_siren", translation_key="siren_detected", - ufp_required_field="can_detect_siren", + ufp_capability=SmartDetectObjectType.SIREN, ufp_public_value="is_siren_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_siren_detection_on"), @@ -373,7 +374,7 @@ class ProtectBinaryEventEntityDescription( ProtectBinaryEntityDescription( key="smart_audio_baby_cry", translation_key="baby_cry_detected", - ufp_required_field="can_detect_baby_cry", + ufp_capability=SmartDetectObjectType.BABY_CRY, ufp_public_value="is_baby_cry_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_baby_cry_detection_on"), @@ -381,7 +382,7 @@ class ProtectBinaryEventEntityDescription( ProtectBinaryEntityDescription( key="smart_audio_speak", translation_key="speaking_detected", - ufp_required_field="can_detect_speaking", + ufp_capability=SmartDetectObjectType.SPEAK, ufp_public_value="is_speaking_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_speaking_detection_on"), @@ -389,7 +390,7 @@ class ProtectBinaryEventEntityDescription( ProtectBinaryEntityDescription( key="smart_audio_bark", translation_key="barking_detected", - ufp_required_field="can_detect_bark", + ufp_capability=SmartDetectObjectType.BARK, ufp_public_value="is_bark_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_bark_detection_on"), @@ -397,7 +398,7 @@ class ProtectBinaryEventEntityDescription( ProtectBinaryEntityDescription( key="smart_audio_car_alarm", translation_key="car_alarm_detected", - ufp_required_field="can_detect_car_alarm", + ufp_capability=SmartDetectObjectType.BURGLAR, ufp_public_value="is_car_alarm_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_car_alarm_detection_on"), @@ -405,7 +406,7 @@ class ProtectBinaryEventEntityDescription( ProtectBinaryEntityDescription( key="smart_audio_car_horn", translation_key="car_horn_detected", - ufp_required_field="can_detect_car_horn", + ufp_capability=SmartDetectObjectType.CAR_HORN, ufp_public_value="is_car_horn_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_car_horn_detection_on"), @@ -413,7 +414,7 @@ class ProtectBinaryEventEntityDescription( ProtectBinaryEntityDescription( key="smart_audio_glass_break", translation_key="glass_break_detected", - ufp_required_field="can_detect_glass_break", + ufp_capability=SmartDetectObjectType.GLASS_BREAK, ufp_public_value="is_glass_break_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_glass_break_detection_on"), diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index b716da2d761bb3..339965075f8e4e 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -19,12 +19,7 @@ from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - device_registry as dr, - entity_platform, - issue_registry as ir, -) -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers import entity_platform, issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity @@ -35,7 +30,6 @@ ATTR_FPS, ATTR_HEIGHT, ATTR_WIDTH, - DEFAULT_BRAND, DOMAIN, ) from .data import ProtectData, ProtectDeviceType, UFPConfigEntry @@ -303,26 +297,6 @@ def _async_set_stream_source(self) -> None: self._attr_supported_features = _ENABLE_FEATURE if source else _DISABLE_FEATURE self._stream_source = source - @callback - @override - def _async_set_device_info(self) -> None: - if self._private is not None: - super()._async_set_device_info() - return - # public-only: no market_name/firmware_version/protect_url, so device - # identity is limited. The NVR link uses the device id registered at - # setup — an API-key-only client has no private bootstrap to read the - # NVR mac from. - public = self._public - self._attr_device_info = DeviceInfo( - name=public.display_name, - model=public.type, - model_id=public.type, - manufacturer=DEFAULT_BRAND, - connections={(dr.CONNECTION_NETWORK_MAC, public.mac)}, - via_device_id=self.data.nvr_device_id, - ) - @callback @override def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index 03927d100fa950..60466d9e8913b2 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -90,6 +90,7 @@ Platform.EVENT, Platform.LIGHT, Platform.SENSOR, + Platform.SWITCH, ] # Stored local-user credentials do not imply the mode: they are kept on a diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 7d9c2cc86b5a5c..cca42c38ed620c 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -80,27 +80,38 @@ def _async_dispatch_id(entry: UFPConfigEntry, dispatch: str) -> str: return f"{DOMAIN}.{entry.entry_id}.{dispatch}" +# Device families the public API alone provides; the private bootstrap has no +# store for them, so hybrid has no adopt path either and their add always goes +# through the public add signal. +_PUBLIC_ONLY_MODELS = {ModelType.FOB, ModelType.RELAY, ModelType.SIREN} + + def _pair_public_private[ PublicDeviceT: PublicDeviceModel, PrivateDeviceT: ProtectAdoptableDeviceModel, ]( public_devices: dict[str, PublicDeviceT], private_devices: dict[str, PrivateDeviceT], + *, + ignore_unadopted: bool = True, ) -> Generator[tuple[PublicDeviceT | None, PrivateDeviceT | None]]: """Pair public-master devices with their private fill by shared id. The public map is the master list; the matching private device is attached when present (hybrid) and ``None`` in public-only mode. An adopted private device not (yet) mirrored publicly is yielded as ``(None, private)`` so the - caller can defer it. Devices not adopted by us are skipped on both sides. + caller can defer it. Devices not adopted by us are skipped on both sides + unless ``ignore_unadopted`` is false (the adopt button needs them). """ for device_id, public in public_devices.items(): private = private_devices.get(device_id) - if private is not None and not private.is_adopted_by_us: + if ignore_unadopted and private is not None and not private.is_adopted_by_us: continue yield public, private for device_id, private in private_devices.items(): - if device_id in public_devices or not private.is_adopted_by_us: + if device_id in public_devices or ( + ignore_unadopted and not private.is_adopted_by_us + ): continue yield None, private @@ -198,6 +209,32 @@ def get_cameras(self, ignore_unadopted: bool = True) -> Generator[Camera]: Generator[Camera], self.get_by_types({ModelType.CAMERA}, ignore_unadopted) ) + def get_public_devices( + self, model_type: ModelType, *, ignore_unadopted: bool = True + ) -> Generator[tuple[PublicDeviceModel | None, ProtectAdoptableDeviceModel | None]]: + """Yield ``(public, private)`` pairs of a model type (see _pair_public_private). + + Without a public bootstrap every private device is yielded unpaired, so + hybrid enumeration is unchanged when the public API is unavailable. + """ + api = self.api + public_devices: dict[str, PublicDeviceModel] = {} + if ( + api.has_public_bootstrap + and (store := api.public_bootstrap.store_for(model_type)) is not None + ): + public_devices = cast(dict[str, PublicDeviceModel], store) + # An API-key-only client never initializes the private bootstrap; + # accessing it would raise. + private_devices: dict[str, ProtectAdoptableDeviceModel] = ( + {} + if api.is_public_only + else async_get_devices_by_type(api.bootstrap, model_type) + ) + yield from _pair_public_private( + public_devices, private_devices, ignore_unadopted=ignore_unadopted + ) + def get_public_cameras( self, ) -> Generator[tuple[PublicCamera | None, Camera | None]]: @@ -299,20 +336,21 @@ async def async_update_public(self) -> None: The first successful refresh fixes the add-dedup baseline: platforms enumerate that snapshot at setup, so only devices appearing later are - offered through the add signal. Public-only mode only, matching the - dispatch gate: hybrid never dispatches adds. + offered through the add signal. Covers the same devices as the + dispatch gate (see _async_uses_public_add). """ await self.api.update_public() if self._public_baseline_taken: return self._public_baseline_taken = True api = self.api - if not api.is_public_only or not api.has_public_bootstrap: + if not api.has_public_bootstrap: return self._known_public_macs.update( device.mac for device in api.public_bootstrap.all_devices() if isinstance(device, PublicDeviceModel) + and self._async_uses_public_add(device) ) @callback @@ -326,20 +364,28 @@ def async_reset_public_add_baseline(self) -> None: self._public_baseline_taken = False self._known_public_macs.clear() + @callback + def _async_uses_public_add(self, device: PublicDeviceModel) -> bool: + """Whether a device is discovered through the public add signal. + + Every device in public-only mode. Hybrid discovers through the private + adopt path, where a second add would clash on unique_id, so only the + families without a private counterpart qualify there. + """ + return self.api.is_public_only or device.model in _PUBLIC_ONLY_MODELS + @callback def _async_dispatch_new_public_device(self, device: PublicDeviceModel) -> None: """Offer a public device to the platforms, once per mac. - Public-only mode only: hybrid discovers new devices through the private - adopt path, and a second add would clash on unique_id. Cameras are - excluded, the channels signal owns their (re-)enumeration. Dedup - happens here so platforms can add without their own duplicate checks. + Cameras are offered too: the channels signal only serves the camera + platform. Dedup happens here so platforms can add without their own + duplicate checks. """ api = self.api if ( - not api.is_public_only - or not api.has_public_bootstrap - or device.model is ModelType.CAMERA + not api.has_public_bootstrap + or not self._async_uses_public_add(device) or device.mac in self._known_public_macs ): return @@ -374,7 +420,7 @@ def _async_process_public_devices_ws_message( if isinstance(new_obj, PublicDeviceModel): if new_obj.model is ModelType.CAMERA: self._async_reenumerate_camera_on_public_change(new_obj, message) - elif message.action is WSAction.ADD: + if message.action is WSAction.ADD: self._async_dispatch_new_public_device(new_obj) self._async_signal_public_update(new_obj.mac, new_obj) @@ -486,10 +532,9 @@ async def _async_resignal_after_public_resync(self) -> None: if self.api.has_public_bootstrap: for public in list(self.api.public_bootstrap.cameras.values()): async_dispatcher_send(self._hass, self.channels_signal, public) - if self.api.is_public_only: - for device in list(self.api.public_bootstrap.all_devices()): - if isinstance(device, PublicDeviceModel): - self._async_dispatch_new_public_device(device) + for device in list(self.api.public_bootstrap.all_devices()): + if isinstance(device, PublicDeviceModel): + self._async_dispatch_new_public_device(device) @callback def _async_signal_nvr_update(self) -> None: diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 8f1bba6124ff78..8ed493caed6300 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -1,17 +1,23 @@ """Shared Entity definition for UniFi Protect Integration.""" -from collections.abc import Callable, Coroutine, Sequence +from collections.abc import Callable, Coroutine, Iterable, Sequence from dataclasses import dataclass from datetime import datetime from enum import Enum from functools import partial import logging from operator import attrgetter -from typing import TYPE_CHECKING, Any, Generic, TypeVar, override +from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast, override -from uiprotect import make_enabled_getter, make_required_getter, make_value_getter +from uiprotect import ( + get_nested_attr_as_bool, + make_enabled_getter, + make_required_getter, + make_value_getter, +) from uiprotect.data import ( NVR, + Camera, DeviceState, Event, Fob, @@ -21,10 +27,15 @@ SmartDetectObjectType, StateType, ) -from uiprotect.data.public_devices import PublicSensor, SensorFeatureCapability +from uiprotect.data.public_devices import ( + PublicCamera, + PublicSensor, + SensorFeatureCapability, +) from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription @@ -54,14 +65,23 @@ class PermRequired(int, Enum): @callback def _async_capability_supported( - data: ProtectData, - device: ProtectAdoptableDeviceModel, + public: PublicDeviceModel | None, + private: ProtectAdoptableDeviceModel | None, description: ProtectEntityDescription, ) -> bool: - """Whether the device advertises the description's required sensor capability.""" + """Whether the device advertises the description's required capability. + + Smart-detect capabilities are answered by the master object (the private + camera in hybrid, the public one otherwise). Sensor capabilities come from + the public capability map; without one every description is created. + """ if (capability := description.ufp_capability) is None: return True - public = data.async_get_public_device(device) + if isinstance(capability, SmartDetectObjectType): + camera = cast( + "Camera | PublicCamera", private if private is not None else public + ) + return camera.can_detect(capability) if not isinstance(public, PublicSensor) or not public.has_feature_flags: return True return public.supports(capability) @@ -80,18 +100,63 @@ def async_remove_unsupported_sense_entities( upgrade then drops the never-functional entities created before the map existed. """ entity_registry = er.async_get(hass) - for device in data.get_by_types({ModelType.SENSOR}): + is_public_only = data.api.is_public_only + for public, private in data.get_public_devices(ModelType.SENSOR): + if private is not None: + mac = private.mac + elif is_public_only and public is not None: + mac = public.mac + else: + # Hybrid: not enumerated until the private fill arrives. + continue for description in descs: if description.ufp_capability is None or _async_capability_supported( - data, device, description + public, private, description ): continue if entity_id := entity_registry.async_get_entity_id( - platform, DOMAIN, f"{device.mac}_{description.key}" + platform, DOMAIN, f"{mac}_{description.key}" ): entity_registry.async_remove(entity_id) +@callback +def _async_public_only_entities( + data: ProtectData, + klass: type[BaseProtectEntity], + public: PublicDeviceModel, + descs: Sequence[ProtectEntityDescription], +) -> list[BaseProtectEntity]: + """Build the entities a public device supports without a private fill. + + Only descriptions reading a public value qualify; the required field and + the capability are checked against the public object. The public API has + no permission model, so ``ufp_perm`` does not apply. + """ + entities: list[BaseProtectEntity] = [] + for description in descs: + if ( + not description.is_public_value + or not description.has_required_public(public) + or not _async_capability_supported(public, None, description) + ): + continue + entities.append( + klass( + data, + device=cast(ProtectDeviceType, public), + description=description, + ) + ) + _LOGGER.debug( + "Adding %s entity %s for %s", + klass.__name__, + description.key, + public.display_name, + ) + return entities + + @callback def _async_device_entities( data: ProtectData, @@ -100,20 +165,31 @@ def _async_device_entities( descs: Sequence[ProtectEntityDescription], unadopted_descs: Sequence[ProtectEntityDescription] | None = None, ufp_device: ProtectAdoptableDeviceModel | None = None, + public_device: PublicDeviceModel | None = None, ) -> list[BaseProtectEntity]: if not descs and not unadopted_descs: return [] + pairs: Iterable[tuple[PublicDeviceModel | None, ProtectAdoptableDeviceModel | None]] + if ufp_device is not None: + pairs = [(data.async_get_public_device(ufp_device), ufp_device)] + elif public_device is not None: + pairs = [(public_device, None)] + else: + pairs = data.get_public_devices(model_type, ignore_unadopted=False) + + api = data.api + is_public_only = api.is_public_only + auth_user = None if is_public_only else api.bootstrap.auth_user entities: list[BaseProtectEntity] = [] - devices = ( - [ufp_device] - if ufp_device is not None - else data.get_by_types({model_type}, ignore_unadopted=False) - ) - auth_user = data.api.bootstrap.auth_user - for device in devices: + for public, device in pairs: + if device is None: + # Hybrid defers a device without private fill to the adopt dispatch. + if is_public_only and public is not None: + entities.extend(_async_public_only_entities(data, klass, public, descs)) + continue if TYPE_CHECKING: - assert isinstance(device, ProtectAdoptableDeviceModel) + assert auth_user is not None if not device.is_adopted_by_us: if unadopted_descs: for description in unadopted_descs: @@ -145,7 +221,7 @@ def _async_device_entities( if not description.has_required(device): continue - if not _async_capability_supported(data, device, description): + if not _async_capability_supported(public, device, description): continue entities.append( @@ -196,9 +272,15 @@ def async_all_device_entities( all_descs: Sequence[ProtectEntityDescription] | None = None, unadopted_descs: list[ProtectEntityDescription] | None = None, ufp_device: ProtectAdoptableDeviceModel | None = None, + public_device: PublicDeviceModel | None = None, ) -> list[BaseProtectEntity]: - """Generate a list of all the device entities.""" - if ufp_device is None: + """Generate a list of all the device entities. + + ``ufp_device`` builds for one adopted private device, ``public_device`` for + one public device without private fill (public-only mode). + """ + device = ufp_device if ufp_device is not None else public_device + if device is None: entities: list[BaseProtectEntity] = [] for model_type in _ALL_MODEL_TYPES: descs = _combine_model_descs(model_type, model_descriptions, all_descs) @@ -207,7 +289,7 @@ def async_all_device_entities( ) return entities - device_model_type = ufp_device.model + device_model_type = device.model assert device_model_type is not None # Runtime adoption must honor the same model-type allowlist as initial setup, # so unsupported devices (e.g. AI Port) get no entities when adopted live. @@ -215,7 +297,13 @@ def async_all_device_entities( return [] descs = _combine_model_descs(device_model_type, model_descriptions, all_descs) return _async_device_entities( - data, klass, device_model_type, descs, unadopted_descs, ufp_device + data, + klass, + device_model_type, + descs, + unadopted_descs, + ufp_device, + public_device, ) @@ -237,17 +325,24 @@ class BaseProtectEntity(Entity): # Values derived from the public events websocket (detection booleans, # public event entities) additionally require that websocket to be healthy. _ufp_requires_events_ws: bool = False + # False when the entity was built from a public object alone (public-only + # mode); ``device`` then holds that object and private fields are absent. + _ufp_has_private: bool = True def __init__( self, data: ProtectData, - device: ProtectDeviceType, + device: ProtectDeviceType | PublicDeviceModel, description: EntityDescription | None = None, ) -> None: """Initialize the entity.""" super().__init__() self.data = data - self.device = device + if isinstance(device, PublicDeviceModel): + 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) if description is None: self._attr_unique_id = self.device.mac @@ -321,6 +416,23 @@ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: if available != was_available: self._attr_available = available + @callback + def _ufp_set_target(self) -> ProtectDeviceType | PublicDeviceModel: + """Return the object a description's setter is called on. + + A migrated description writes through the public object it reads from, + in both connection modes; the private device serves the rest. + """ + if not self._ufp_uses_public: + return self.device + if (public := self._ufp_public_obj) is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_not_available", + translation_placeholders={"device_name": self.device.display_name}, + ) + return public + @callback def _async_updated_event(self, device: ProtectDeviceType) -> None: """When device is updated from Protect.""" @@ -368,10 +480,7 @@ async def async_added_to_hass(self) -> None: # Not every entity carries an entity_description (e.g. cameras), so getattr. description = getattr(self, "entity_description", None) if isinstance(description, ProtectEntityDescription): - if ( - description.ufp_public_value is not None - or description.ufp_public_value_fn is not None - ): + if description.is_public_value: self._ufp_uses_public = True if description.ufp_event_driven: self._ufp_requires_events_ws = True @@ -412,6 +521,21 @@ class ProtectDeviceEntity(BaseProtectEntity): @callback @override def _async_set_device_info(self) -> None: + if not self._ufp_has_private: + # 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, + manufacturer=DEFAULT_BRAND, + connections={(dr.CONNECTION_NETWORK_MAC, public.mac)}, + via_device_id=self.data.nvr_device_id, + ) + return self._attr_device_info = DeviceInfo( name=self.device.display_name, manufacturer=DEFAULT_BRAND, @@ -597,17 +721,26 @@ class ProtectEntityDescription(EntityDescription, Generic[T]): # noqa: UP046 # Public counterpart of ``ufp_enabled``; a callable because public enablement # is often compound (e.g. mount type plus a settings flag). ufp_public_enabled_fn: Callable[[PublicDeviceModel], bool] | None = None - # Sensor capability required to create the entity, checked against the public - # capability map. Without a capability map (older firmware) every description - # is created, matching the pre-capability behavior. - ufp_capability: SensorFeatureCapability | None = None + # Capability required to create the entity: a sensor capability is checked + # against the public capability map (without one every description is + # created), a smart-detect type against the camera's advertised types. + ufp_capability: SensorFeatureCapability | SmartDetectObjectType | None = None ufp_perm: PermRequired | None = None # The below are set in __post_init__ has_required: Callable[[T], bool] = bool + # ``ufp_required_field`` against the public object; an attribute path the + # public model lacks reads as False, so private-only descriptions are + # skipped in public-only mode. + has_required_public: Callable[[PublicDeviceModel], bool] = bool get_ufp_enabled: Callable[[T], bool] | None = None get_ufp_public_value: Callable[[PublicDeviceModel], Any] | None = None + @property + def is_public_value(self) -> bool: + """Whether the value is read from the public object.""" + return self.ufp_public_value is not None or self.ufp_public_value_fn is not None + def get_ufp_value(self, obj: T) -> Any: """Return value from UniFi Protect device; overridden in __post_init__.""" # ufp_value or ufp_value_fn are required, the @@ -648,6 +781,10 @@ def __post_init__(self) -> None: if (ufp_required_field := self.ufp_required_field) is not None: _setter("has_required", make_required_getter(ufp_required_field)) + _setter( + "has_required_public", + partial(get_nested_attr_as_bool, tuple(ufp_required_field.split("."))), + ) @dataclass(frozen=True, kw_only=True) @@ -679,10 +816,12 @@ def __post_init__(self) -> None: class ProtectSettableKeysMixin(ProtectEntityDescription[T]): """Mixin for settable values.""" + # Called on the object the value is read from: the public object for a + # migrated description, the private device otherwise. ufp_set_method: str | None = None - ufp_set_method_fn: Callable[[T, Any], Coroutine[Any, Any, None]] | None = None + ufp_set_method_fn: Callable[[Any, Any], Coroutine[Any, Any, None]] | None = None - async def ufp_set(self, obj: T, value: Any) -> None: + async def ufp_set(self, obj: T | PublicDeviceModel, value: Any) -> None: """Set value for UniFi Protect device.""" _LOGGER.debug("Setting %s to %s for %s", self.key, value, obj.display_name) if self.ufp_set_method is not None: diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index f2266acc55be04..f6dae433714229 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -14,12 +14,10 @@ from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DEFAULT_BRAND, DOMAIN +from .const import DOMAIN from .data import ProtectData, ProtectDeviceType, UFPConfigEntry from .entity import ProtectDeviceEntity from .utils import async_ufp_instance_command @@ -107,30 +105,11 @@ def __init__( private: Light | None, ) -> None: """Initialize the light.""" - self._private = private 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)) - @callback - @override - def _async_set_device_info(self) -> None: - if self._private is not None: - super()._async_set_device_info() - return - # market_name/firmware/URL are private-only; the NVR link uses the - # device id registered at setup. - public = cast(PublicLight, self.device) - self._attr_device_info = DeviceInfo( - name=public.display_name, - model=public.type, - model_id=public.type, - manufacturer=DEFAULT_BRAND, - connections={(dr.CONNECTION_NETWORK_MAC, public.mac)}, - via_device_id=self.data.nvr_device_id, - ) - @callback @override def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 3499bfe0b0903b..3325a5b907e653 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -52,8 +52,8 @@ def _get_pir_duration_public(obj: PublicDeviceModel) -> int | None: return None if duration is None else round(duration / 1000) -async def _set_pir_duration(obj: Light, value: float) -> None: - await obj.set_duration_public(timedelta(seconds=value)) +async def _set_pir_duration(obj: PublicLight, value: float) -> None: + await obj.set_duration(timedelta(seconds=value)) def _get_chime_duration(obj: Camera) -> int: @@ -91,7 +91,7 @@ async def _set_chime_volume(obj: Chime, value: float) -> None: ufp_step=1, ufp_required_field="has_mic", ufp_public_value="mic_volume", - ufp_set_method="set_mic_volume_public", + ufp_set_method="set_mic_volume", ufp_perm=PermRequired.WRITE, ), ProtectNumberEntityDescription( @@ -174,7 +174,7 @@ async def _set_chime_volume(obj: Chime, value: float) -> None: ufp_max=100, ufp_step=1, ufp_public_value="light_device_settings.pir_sensitivity", - ufp_set_method="set_sensitivity_public", + ufp_set_method="set_sensitivity", ufp_perm=PermRequired.WRITE, ), ProtectNumberEntityDescription[Light]( @@ -201,7 +201,7 @@ async def _set_chime_volume(obj: Chime, value: float) -> None: ufp_max=100, ufp_step=1, ufp_public_value="motion_settings.sensitivity", - ufp_set_method="set_motion_sensitivity_public", + ufp_set_method="set_motion_sensitivity", ufp_capability=SensorFeatureCapability.MOTION, ), ) @@ -337,7 +337,7 @@ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: @override async def async_set_native_value(self, value: float) -> None: """Set new value.""" - await self.entity_description.ufp_set(self.device, value) + await self.entity_description.ufp_set(self._ufp_set_target(), value) class ChimeRingVolumeNumber(ProtectDeviceEntity, NumberEntity): diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 363f4d3c6addb3..d4a4a0ee5e2664 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -28,6 +28,7 @@ from uiprotect.data.public_devices import ( PublicCamera, PublicDeviceModel, + PublicLight, SensorFeatureCapability, ) from uiprotect.exceptions import GlobalAlarmManagerError @@ -173,9 +174,9 @@ def _get_doorbell_current(obj: Camera) -> str | None: return obj.lcd_message.text -async def _set_light_mode(obj: Light, mode: str) -> None: +async def _set_light_mode(obj: PublicLight, mode: str) -> None: lightmode, timing = LIGHT_MODE_TO_SETTINGS[mode] - await obj.set_light_mode_public( + await obj.set_light_mode( LightModeType(lightmode), enable_at=None if timing is None else LightModeEnableType(timing), ) @@ -234,9 +235,9 @@ def _get_hdr_mode_public(obj: PublicDeviceModel) -> str | None: return _HDR_MODE_MAP_INVERSE.get(cast(PublicCamera, obj).hdr_type) -async def _set_hdr_mode(obj: Camera, mode: str) -> None: +async def _set_hdr_mode(obj: PublicCamera, mode: str) -> None: """Set HDR mode via the public API.""" - await obj.set_hdr_mode_public(_HDR_MODE_MAP[mode]) + await obj.set_hdr_mode(_HDR_MODE_MAP[mode]) PTZ_PATROL_DESCRIPTION = ProtectSelectEntityDescription[Camera]( @@ -475,13 +476,15 @@ async def async_select_option(self, option: str) -> None: # Light Motion is a bit different if self.entity_description.key == _KEY_LIGHT_MOTION: assert self.entity_description.ufp_set_method_fn is not None - await self.entity_description.ufp_set_method_fn(self.device, option) + await self.entity_description.ufp_set_method_fn( + self._ufp_set_target(), option + ) return unifi_value = self._hass_to_unifi_options[option] if self.entity_description.ufp_enum_type is not None: unifi_value = self.entity_description.ufp_enum_type(unifi_value) - await self.entity_description.ufp_set(self.device, unifi_value) + await self.entity_description.ufp_set(self._ufp_set_target(), unifi_value) class ProtectPTZPatrolSelect(ProtectDeviceEntity, SelectEntity): diff --git a/homeassistant/components/unifiprotect/siren.py b/homeassistant/components/unifiprotect/siren.py index 4256c022f32c46..5b6cabfa928b8d 100644 --- a/homeassistant/components/unifiprotect/siren.py +++ b/homeassistant/components/unifiprotect/siren.py @@ -16,6 +16,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.util import dt as dt_util @@ -40,6 +41,17 @@ async def async_setup_entry( """Set up UniFi Protect siren entities from a config entry.""" data: ProtectData = entry.runtime_data + @callback + def _add_new_public_device(device: PublicDeviceModel) -> None: + # A siren has no private counterpart, so the adopt path never offers + # one; it arrives through the public add signal in both modes. + if isinstance(device, Siren): + async_add_entities([ProtectSiren(data, device)]) + + entry.async_on_unload( + async_dispatcher_connect(hass, data.public_add_signal, _add_new_public_device) + ) + api = data.api if not api.has_public_bootstrap: return diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index debed2d482a7be..06f2878e00d77d 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -841,6 +841,9 @@ "command_error": { "message": "Error communicating with UniFi Protect while sending command: {error}" }, + "device_not_available": { + "message": "{device_name} is no longer available" + }, "entry_auth_failed": { "message": "Authentication failed, please reauthenticate" }, diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index 3aeedcb056088b..f35470a5d873b5 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -15,9 +15,10 @@ RecordingMode, Relay, RelayOutputState, + SmartDetectObjectType, VideoMode, ) -from uiprotect.data.public_devices import SensorFeatureCapability +from uiprotect.data.public_devices import PublicCamera, SensorFeatureCapability from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory, Platform @@ -25,6 +26,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -56,8 +58,8 @@ class ProtectSwitchEntityDescription( """Describes UniFi Protect Switch entity.""" -async def _set_highfps(obj: Camera, value: bool) -> None: - await obj.set_video_mode_public(VideoMode.HIGH_FPS if value else VideoMode.DEFAULT) +async def _set_highfps(obj: PublicCamera, value: bool) -> None: + await obj.set_video_mode(VideoMode.HIGH_FPS if value else VideoMode.DEFAULT) async def _set_hdr(obj: Camera, value: bool) -> None: @@ -80,7 +82,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_led_status", ufp_public_value="led_settings.is_enabled", - ufp_set_method="set_status_light_public", + ufp_set_method="set_status_light", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription[Camera]( @@ -97,6 +99,9 @@ async def _set_hdr(obj: Camera, value: bool) -> None: key="high_fps", translation_key="high_fps", entity_category=EntityCategory.CONFIG, + # has_highfps has no public counterpart yet (uilibs/uiprotect#1201), so + # this stays unreachable in API-key-only mode even though the value + # and setter are migrated. ufp_required_field="feature_flags.has_highfps", ufp_public_value="is_high_fps_enabled", ufp_set_method_fn=_set_highfps, @@ -117,7 +122,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="overlay_show_name", entity_category=EntityCategory.CONFIG, ufp_public_value="osd_settings.is_name_enabled", - ufp_set_method="set_osd_name_public", + ufp_set_method="set_osd_name", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -125,7 +130,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="overlay_show_date", entity_category=EntityCategory.CONFIG, ufp_public_value="osd_settings.is_date_enabled", - ufp_set_method="set_osd_date_public", + ufp_set_method="set_osd_date", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -133,7 +138,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="overlay_show_logo", entity_category=EntityCategory.CONFIG, ufp_public_value="osd_settings.is_logo_enabled", - ufp_set_method="set_osd_logo_public", + ufp_set_method="set_osd_logo", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -141,7 +146,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="overlay_show_nerd_mode", entity_category=EntityCategory.CONFIG, ufp_public_value="osd_settings.is_debug_enabled", - ufp_set_method="set_osd_nerd_mode_public", + ufp_set_method="set_osd_nerd_mode", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -166,127 +171,127 @@ async def _set_hdr(obj: Camera, value: bool) -> None: key="smart_person", translation_key="detections_person", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_person", + ufp_capability=SmartDetectObjectType.PERSON, ufp_public_value="is_person_detection_on", - ufp_set_method="set_person_detection_public", + ufp_set_method="set_person_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_vehicle", translation_key="detections_vehicle", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_vehicle", + ufp_capability=SmartDetectObjectType.VEHICLE, ufp_public_value="is_vehicle_detection_on", - ufp_set_method="set_vehicle_detection_public", + ufp_set_method="set_vehicle_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_animal", translation_key="detections_animal", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_animal", + ufp_capability=SmartDetectObjectType.ANIMAL, ufp_public_value="is_animal_detection_on", - ufp_set_method="set_animal_detection_public", + ufp_set_method="set_animal_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_package", translation_key="detections_package", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_package", + ufp_capability=SmartDetectObjectType.PACKAGE, ufp_public_value="is_package_detection_on", - ufp_set_method="set_package_detection_public", + ufp_set_method="set_package_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_licenseplate", translation_key="detections_license_plate", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_license_plate", + ufp_capability=SmartDetectObjectType.LICENSE_PLATE, ufp_public_value="is_license_plate_detection_on", - ufp_set_method="set_license_plate_detection_public", + ufp_set_method="set_license_plate_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_smoke", translation_key="detections_smoke", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_smoke", + ufp_capability=SmartDetectObjectType.SMOKE, ufp_public_value="is_smoke_detection_on", - ufp_set_method="set_smoke_detection_public", + ufp_set_method="set_smoke_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_cmonx", translation_key="detections_co_alarm", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_co", + ufp_capability=SmartDetectObjectType.CMONX, ufp_public_value="is_co_detection_on", - ufp_set_method="set_co_detection_public", + ufp_set_method="set_co_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_siren", translation_key="detections_siren", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_siren", + ufp_capability=SmartDetectObjectType.SIREN, ufp_public_value="is_siren_detection_on", - ufp_set_method="set_siren_detection_public", + ufp_set_method="set_siren_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_baby_cry", translation_key="detections_baby_cry", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_baby_cry", + ufp_capability=SmartDetectObjectType.BABY_CRY, ufp_public_value="is_baby_cry_detection_on", - ufp_set_method="set_baby_cry_detection_public", + ufp_set_method="set_baby_cry_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_speak", translation_key="detections_speak", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_speaking", + ufp_capability=SmartDetectObjectType.SPEAK, ufp_public_value="is_speaking_detection_on", - ufp_set_method="set_speaking_detection_public", + ufp_set_method="set_speaking_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_bark", translation_key="detections_bark", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_bark", + ufp_capability=SmartDetectObjectType.BARK, ufp_public_value="is_bark_detection_on", - ufp_set_method="set_bark_detection_public", + ufp_set_method="set_bark_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_car_alarm", translation_key="detections_car_alarm", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_car_alarm", + ufp_capability=SmartDetectObjectType.BURGLAR, # Public API renamed "car alarm" to "burglar"; internal model keeps the legacy name. ufp_public_value="is_car_alarm_detection_on", - ufp_set_method="set_burglar_detection_public", + ufp_set_method="set_burglar_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_car_horn", translation_key="detections_car_horn", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_car_horn", + ufp_capability=SmartDetectObjectType.CAR_HORN, ufp_public_value="is_car_horn_detection_on", - ufp_set_method="set_car_horn_detection_public", + ufp_set_method="set_car_horn_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_glass_break", translation_key="detections_glass_break", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_glass_break", + ufp_capability=SmartDetectObjectType.GLASS_BREAK, ufp_public_value="is_glass_break_detection_on", - ufp_set_method="set_glass_break_detection_public", + ufp_set_method="set_glass_break_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -325,7 +330,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="detections_motion", entity_category=EntityCategory.CONFIG, ufp_public_value="motion_settings.is_enabled", - ufp_set_method="set_motion_status_public", + ufp_set_method="set_motion_status", ufp_capability=SensorFeatureCapability.MOTION, ), ProtectSwitchEntityDescription( @@ -333,7 +338,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="temperature_sensor", entity_category=EntityCategory.CONFIG, ufp_public_value="temperature_settings.is_enabled", - ufp_set_method="set_temperature_status_public", + ufp_set_method="set_temperature_status", ufp_capability=SensorFeatureCapability.TEMPERATURE, ), ProtectSwitchEntityDescription( @@ -341,7 +346,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="humidity_sensor", entity_category=EntityCategory.CONFIG, ufp_public_value="humidity_settings.is_enabled", - ufp_set_method="set_humidity_status_public", + ufp_set_method="set_humidity_status", ufp_capability=SensorFeatureCapability.HUMIDITY, ), ProtectSwitchEntityDescription( @@ -349,7 +354,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="light_sensor", entity_category=EntityCategory.CONFIG, ufp_public_value="light_settings.is_enabled", - ufp_set_method="set_light_status_public", + ufp_set_method="set_light_status", ufp_capability=SensorFeatureCapability.LIGHT, ), ProtectSwitchEntityDescription( @@ -357,7 +362,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="alarm_sound_detection", entity_category=EntityCategory.CONFIG, ufp_public_value="alarm_settings.is_enabled", - ufp_set_method="set_alarm_public", + ufp_set_method="set_alarm", ufp_capability=SensorFeatureCapability.SMOKE, ), ) @@ -378,7 +383,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="status_light", entity_category=EntityCategory.CONFIG, ufp_public_value="light_device_settings.is_indicator_enabled", - ufp_set_method="set_status_light_public", + ufp_set_method="set_status_light", ufp_perm=PermRequired.WRITE, ), ) @@ -438,12 +443,12 @@ class ProtectBaseSwitch(ProtectIsOnEntity): @async_ufp_instance_command async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - await self.entity_description.ufp_set(self.device, True) + await self.entity_description.ufp_set(self._ufp_set_target(), True) @async_ufp_instance_command async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - await self.entity_description.ufp_set(self.device, False) + await self.entity_description.ufp_set(self._ufp_set_target(), False) class ProtectSwitch(ProtectDeviceEntity, ProtectBaseSwitch, SwitchEntity): @@ -550,33 +555,57 @@ def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: entities += _make_entities(ProtectPrivacyModeSwitch, _PRIVACY_DESCRIPTIONS) async_add_entities(entities) + @callback + def _add_new_public_device(device: PublicDeviceModel) -> None: + if isinstance(device, Relay): + async_add_entities(_relay_output_switches(data, device)) + return + async_add_entities( + async_all_device_entities( + data, ProtectSwitch, _MODEL_DESCRIPTIONS, public_device=device + ) + ) + _make_entities = partial(async_all_device_entities, data) data.async_subscribe_adopt(_add_new_device) + entry.async_on_unload( + async_dispatcher_connect(hass, data.public_add_signal, _add_new_public_device) + ) entities: list[BaseProtectEntity] = [] entities += _make_entities(ProtectSwitch, _MODEL_DESCRIPTIONS) entities += _make_entities(ProtectPrivacyModeSwitch, _PRIVACY_DESCRIPTIONS) - bootstrap = data.api.bootstrap - nvr = bootstrap.nvr - if nvr.can_write(bootstrap.auth_user) and nvr.is_insights_enabled is not None: - entities.extend( - ProtectNVRSwitch(data, device=nvr, description=switch) - for switch in NVR_SWITCHES - ) + api = data.api + if not api.is_public_only: + # The NVR switches are private-only settings. + bootstrap = api.bootstrap + nvr = bootstrap.nvr + if nvr.can_write(bootstrap.auth_user) and nvr.is_insights_enabled is not None: + entities.extend( + ProtectNVRSwitch(data, device=nvr, description=switch) + for switch in NVR_SWITCHES + ) async_add_entities(entities) - # Public API: relay output switches. Only available when the public - # bootstrap has been primed (requires API key + supported NVR firmware). - api = data.api + # Relays exist only in the public API; a relay adopted later arrives + # through the public add signal in either mode. if api.has_public_bootstrap: - relay_entities: list[ProtectRelayOutputSwitch] = [ - ProtectRelayOutputSwitch(data, relay, output) + relay_entities = [ + entity for relay in api.public_bootstrap.relays.values() - for output in relay.outputs + for entity in _relay_output_switches(data, relay) ] if relay_entities: async_add_entities(relay_entities) +@callback +def _relay_output_switches( + data: ProtectData, relay: Relay +) -> list[ProtectRelayOutputSwitch]: + """Build one switch per output channel of a relay.""" + return [ProtectRelayOutputSwitch(data, relay, output) for output in relay.outputs] + + class ProtectRelayOutputSwitch(SwitchEntity): """Switch entity for a single relay output channel (Public API). diff --git a/homeassistant/components/unifiprotect/text.py b/homeassistant/components/unifiprotect/text.py index 2ffde3182f0c54..94295231bf0732 100644 --- a/homeassistant/components/unifiprotect/text.py +++ b/homeassistant/components/unifiprotect/text.py @@ -110,4 +110,4 @@ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: @override async def async_set_value(self, value: str) -> None: """Change the value.""" - await self.entity_description.ufp_set(self.device, value) + await self.entity_description.ufp_set(self._ufp_set_target(), value) diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index a265e0d262d5e3..960c03d5a677c8 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -1,6 +1,6 @@ """Fixtures and test data for UniFi Protect methods.""" -from collections.abc import Callable, Coroutine, Generator, Iterator +from collections.abc import Callable, Coroutine, Generator from datetime import datetime, timedelta from functools import partial from ipaddress import IPv4Address @@ -24,8 +24,6 @@ ModelType, NvrArmMode, NvrArmModeStatus, - ProtectModelWithId, - PublicBootstrap, Sensor, SmartDetectObjectType, StateType, @@ -56,7 +54,12 @@ from homeassistant.util import dt as dt_util from . import _patch_discovery -from .utils import MockUFPFixture, make_public_camera, public_rtsps_for +from .utils import ( + MockUFPFixture, + make_public_bootstrap, + make_public_camera, + public_rtsps_for, +) from tests.common import MockConfigEntry, load_json_object_fixture @@ -191,17 +194,11 @@ async def get_nvr(*args: Any, **kwargs: Any) -> NVR: client.get_meta_info = AsyncMock(return_value=meta) # The library owns RTSPS streams on ``PublicCamera.rtsps_streams`` and primes - # them in ``update_public()``; the integration reads them synchronously. Start - # with empty collections; the ``update_public`` side effect (see ``mock_entry``) - # primes the cameras from the private bootstrap. - client.public_bootstrap = Mock(spec=PublicBootstrap) - client.public_bootstrap.cameras = {} - client.public_bootstrap.lights = {} - client.public_bootstrap.relays = {} - client.public_bootstrap.sirens = {} - client.public_bootstrap.fobs = {} - client.public_bootstrap.arm_profiles = {} - client.public_bootstrap.arm_mode = None + # them in ``update_public()``; the integration reads them synchronously. The + # ``update_public`` side effect (see ``mock_entry``) primes the cameras from + # the private bootstrap; other device families opt in via the + # ``setup_public_*`` helpers and default to no paired public object. + client.public_bootstrap = make_public_bootstrap() client.public_bootstrap.nvr = Mock() client.public_bootstrap.nvr.mac = nvr.mac client.public_bootstrap.nvr.name = nvr.name @@ -209,31 +206,6 @@ async def get_nvr(*args: Any, **kwargs: Any) -> NVR: client.public_bootstrap.nvr.device_type = None client.public_bootstrap.nvr.type = None - # Cameras and lights resolve to their primed public model (see - # ``update_public`` in ``mock_entry`` / ``setup_public_light``); other - # device types opt in via the ``setup_public_*`` helpers, so they default - # to no paired public object. - def _public_bootstrap_get( - model: ModelType, obj_id: str - ) -> ProtectModelWithId | None: - if model is ModelType.CAMERA: - return client.public_bootstrap.cameras.get(obj_id) - if model is ModelType.LIGHT: - return client.public_bootstrap.lights.get(obj_id) - return None - - client.public_bootstrap.get = Mock(side_effect=_public_bootstrap_get) - - def _public_all_devices() -> Iterator[Mock]: - pb = client.public_bootstrap - yield from pb.cameras.values() - yield from pb.lights.values() - yield from pb.relays.values() - yield from pb.sirens.values() - yield from pb.fobs.values() - - client.public_bootstrap.all_devices = _public_all_devices - async def get_camera_rtsps_streams( camera_id: str, *args: Any, **kwargs: Any ) -> RTSPSStreams | None: @@ -647,33 +619,11 @@ def mock_ufp_public_only_client() -> Mock: nvr.type = "UNVR4" nvr.id = "nvr-id" nvr.model = ModelType.NVR - pb = Mock(spec=PublicBootstrap) + # Tests replace whole device maps (``pb.lights = {...}``); the helper reads + # them at call time. + pb = make_public_bootstrap() pb.nvr = nvr pb.arm_mode = arm_mode - # One map per device family forwarded in public-only mode; tests replace - # them, so both helpers below read the attribute at call time. - pb.cameras = {} - pb.lights = {} - pb.fobs = {} - device_maps = { - ModelType.CAMERA: "cameras", - ModelType.LIGHT: "lights", - ModelType.FOB: "fobs", - } - - def _all_devices(*, include_nvr: bool = False) -> Iterator[Mock]: - if include_nvr and pb.nvr is not None: - yield pb.nvr - for attr in device_maps.values(): - yield from getattr(pb, attr).values() - - def _get(model: ModelType, obj_id: str) -> Mock | None: - if (attr := device_maps.get(model)) is None: - return None - return getattr(pb, attr).get(obj_id) - - pb.all_devices = _all_devices - pb.get = Mock(side_effect=_get) client.public_bootstrap = pb return client diff --git a/tests/components/unifiprotect/test_alarm_control_panel.py b/tests/components/unifiprotect/test_alarm_control_panel.py index 2c2dc41d8e5d27..ac2f0db68817e0 100644 --- a/tests/components/unifiprotect/test_alarm_control_panel.py +++ b/tests/components/unifiprotect/test_alarm_control_panel.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, Mock import pytest -from uiprotect.data import NVR, NvrArmMode, NvrArmModeStatus, PublicBootstrap +from uiprotect.data import NVR, NvrArmMode, NvrArmModeStatus from uiprotect.exceptions import GlobalAlarmManagerError from uiprotect.websocket import WebsocketState @@ -27,7 +27,12 @@ from homeassistant.helpers import entity_registry as er from .conftest import PUBLIC_ONLY_ALARM_ENTITY_ID -from .utils import MockUFPFixture, assert_entity_counts, init_entry +from .utils import ( + MockUFPFixture, + assert_entity_counts, + init_entry, + make_public_bootstrap, +) ALARM_ENTITY_ID = "alarm_control_panel.unifiprotect_alarm_manager" @@ -41,13 +46,7 @@ def _make_arm_mode(status: NvrArmModeStatus) -> Mock: def _make_public_bootstrap(arm_mode: Mock | None) -> Mock: """Create a PublicBootstrap with the given arm_mode.""" - pb = Mock(spec=PublicBootstrap) - pb.arm_mode = arm_mode - pb.arm_profiles = {} - pb.relays = {} - pb.sirens = {} - pb.fobs = {} - return pb + return make_public_bootstrap(arm_mode=arm_mode) async def test_alarm_panel_not_created_without_public_bootstrap( diff --git a/tests/components/unifiprotect/test_fob.py b/tests/components/unifiprotect/test_fob.py index b8cec638d26d1b..cf960cda957fcb 100644 --- a/tests/components/unifiprotect/test_fob.py +++ b/tests/components/unifiprotect/test_fob.py @@ -634,3 +634,56 @@ async def test_fob_added_at_runtime( ) == 5 ) + + +async def test_fob_added_after_setup_in_hybrid( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + caplog: pytest.LogCaptureFixture, +) -> None: + """A fob paired after setup gets its entities in hybrid mode too. + + The private bootstrap has no store for fobs, so the adopt path never sees + one; discovery goes through the public add signal in both modes. A + re-delivered frame must not add a second time. + """ + ufp.api.has_public_bootstrap = True + pb = _make_public_bootstrap(None) + ufp.api.public_bootstrap = pb + ufp.api.update_public = AsyncMock(return_value=pb) + + await init_entry(hass, ufp, []) + assert entity_registry.async_get(BATTERY_SENSOR) is None + + fob = _make_fob() + pb.fobs = {fob.id: fob} + msg = public_device_ws_message(fob) + msg.action = WSAction.ADD + assert ufp.devices_ws_subscription is not None + ufp.devices_ws_subscription(msg) + await hass.async_block_till_done() + + assert entity_registry.async_get(BATTERY_SENSOR) is not None + count = len( + [ + entry + for entry in entity_registry.entities.values() + if entry.unique_id.startswith(FOB_MAC) + ] + ) + + ufp.devices_ws_subscription(msg) + await hass.async_block_till_done() + + assert "already exists" not in caplog.text + assert ( + len( + [ + entry + for entry in entity_registry.entities.values() + if entry.unique_id.startswith(FOB_MAC) + ] + ) + == count + ) diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index a7e265a6ee13a7..6bf8c840bfe9bb 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -1,7 +1,7 @@ """Test the UniFi Protect number platform.""" from datetime import timedelta -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch import pytest from uiprotect.data import ( @@ -181,9 +181,11 @@ async def test_number_light_sensitivity( hass, Platform.NUMBER, light, description ) - with patch_ufp_method( - light, "set_sensitivity_public", new_callable=AsyncMock - ) as mock_method: + public = make_public_light(light) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + with patch.object(public, "set_sensitivity", new_callable=AsyncMock) as mock_method: await hass.services.async_call( "number", "set_value", @@ -242,9 +244,11 @@ async def test_number_light_duration( hass, Platform.NUMBER, light, description ) - with patch_ufp_method( - light, "set_duration_public", new_callable=AsyncMock - ) as mock_method: + public = make_public_light(light) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + with patch.object(public, "set_duration", new_callable=AsyncMock) as mock_method: await hass.services.async_call( "number", "set_value", @@ -328,14 +332,16 @@ async def test_number_light_duration_none( assert hass.states.get(entity_id).state == STATE_UNKNOWN -@pytest.mark.parametrize("description", CAMERA_NUMBERS) +@pytest.mark.parametrize( + "description", [d for d in CAMERA_NUMBERS if not d.is_public_value] +) async def test_number_camera_simple( hass: HomeAssistant, ufp: MockUFPFixture, camera_all_features: Camera, description: ProtectNumberEntityDescription, ) -> None: - """Tests simple numbers for cameras using the all features fixture.""" + """Tests the private-API numbers for cameras using the all features fixture.""" setup_public_camera(ufp) await init_entry(hass, ufp, [camera_all_features]) assert_entity_counts(hass, Platform.NUMBER, 7, 7) @@ -359,6 +365,33 @@ async def test_number_camera_simple( mock_method.assert_called_once_with(1.0) +async def test_number_camera_mic_volume_set( + hass: HomeAssistant, ufp: MockUFPFixture, camera_all_features: Camera +) -> None: + """The migrated mic volume number writes through the public object.""" + setup_public_camera(ufp) + await init_entry(hass, ufp, [camera_all_features]) + + description = next(d for d in CAMERA_NUMBERS if d.key == "mic_level") + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, camera_all_features, description + ) + + public = make_public_camera(camera_all_features) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + with patch.object(public, "set_mic_volume", new_callable=AsyncMock) as mock_method: + await hass.services.async_call( + "number", + "set_value", + {ATTR_ENTITY_ID: entity_id, "value": 1.0}, + blocking=True, + ) + + mock_method.assert_called_once_with(1.0) + + async def test_number_camera_mic_volume_public_value( hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera ) -> None: @@ -481,8 +514,12 @@ async def test_number_sense_sensitivity_set( hass, Platform.NUMBER, sensor_all, SENSE_NUMBERS[0] ) - with patch_ufp_method( - sensor_all, "set_motion_sensitivity_public", new_callable=AsyncMock + public = make_public_sensor(sensor_all) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + with patch.object( + public, "set_motion_sensitivity", new_callable=AsyncMock ) as mock_method: await hass.services.async_call( "number", diff --git a/tests/components/unifiprotect/test_relay.py b/tests/components/unifiprotect/test_relay.py index f84c892868ba81..b4107c90e6072c 100644 --- a/tests/components/unifiprotect/test_relay.py +++ b/tests/components/unifiprotect/test_relay.py @@ -1,15 +1,16 @@ """Tests for the UniFi Protect relay (Public API) switch entities.""" -from collections.abc import Callable +from collections.abc import Callable, Coroutine +from typing import Any from unittest.mock import AsyncMock, Mock import pytest from uiprotect.data import ( ModelType, - PublicBootstrap, PublicRelayOutput, Relay, RelayOutputState, + WSAction, ) from uiprotect.exceptions import ClientError, NotAuthorized from uiprotect.websocket import WebsocketState @@ -29,7 +30,12 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from .utils import MockUFPFixture, init_entry +from .utils import ( + MockUFPFixture, + init_entry, + make_public_bootstrap, + public_device_ws_message, +) RELAY_ID = "relay-id-1" RELAY_MAC = "AA:BB:CC:DD:EE:01" @@ -75,13 +81,7 @@ def get_output(output_id: int) -> Mock | None: def _make_public_bootstrap(relay: Mock | None) -> Mock: """Build a public bootstrap mock holding the given relay.""" - pb = Mock(spec=PublicBootstrap) - pb.relays = {relay.id: relay} if relay is not None else {} - pb.arm_mode = None - pb.arm_profiles = {} - pb.sirens = {} - pb.fobs = {} - return pb + return make_public_bootstrap(relays={relay.id: relay} if relay is not None else {}) @pytest.fixture(name="ufp_with_relay") @@ -602,3 +602,113 @@ async def test_relay_switch_command_when_output_gone( {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, blocking=True, ) + + +async def test_relay_switch_public_only( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp_public_only: MockUFPFixture, + setup_public_only: Callable[[], Coroutine[Any, Any, None]], +) -> None: + """Relay output switches are public-API entities and work in API-key-only mode.""" + relay = _make_relay() + relay.outputs[0].state = RelayOutputState.ON + ufp_public_only.api.public_bootstrap.relays = {relay.id: relay} + + await setup_public_only() + + assert entity_registry.async_get(SWITCH_ENTITY_ID) is not None + assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, + blocking=True, + ) + relay.activate_output.assert_awaited_once_with(OUTPUT_ID, state="off") + + +@pytest.fixture(name="setup_hybrid") +def setup_hybrid_fixture( + hass: HomeAssistant, ufp: MockUFPFixture +) -> Callable[[], Coroutine[Any, Any, None]]: + """Return a callable setting up the hybrid entry with an empty public bootstrap.""" + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = _make_public_bootstrap(None) + + async def _setup() -> None: + await init_entry(hass, ufp, []) + + return _setup + + +def _add_relay_frame(ufp: MockUFPFixture, relay: Mock) -> None: + """Deliver a public devices websocket add frame for ``relay``.""" + ufp.api.public_bootstrap.relays[relay.id] = relay + msg = public_device_ws_message(relay) + msg.action = WSAction.ADD + ufp.devices_ws_subscription(msg) + + +@pytest.mark.parametrize( + ("ufp_fixture", "setup_fixture"), + [ + pytest.param("ufp_public_only", "setup_public_only", id="public_only"), + pytest.param("ufp", "setup_hybrid", id="hybrid"), + ], +) +async def test_relay_switch_added_after_setup( + hass: HomeAssistant, + request: pytest.FixtureRequest, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, + ufp_fixture: str, + setup_fixture: str, +) -> None: + """A relay adopted after setup gets its switches from its add frame in both modes. + + A relay has no private counterpart, so hybrid cannot discover it through + the adopt path either. A re-delivered frame must not add a second time. + """ + ufp: MockUFPFixture = request.getfixturevalue(ufp_fixture) + setup: Callable[[], Coroutine[Any, Any, None]] = request.getfixturevalue( + setup_fixture + ) + await setup() + assert entity_registry.async_get(SWITCH_ENTITY_ID) is None + + relay = _make_relay() + _add_relay_frame(ufp, relay) + await hass.async_block_till_done() + + assert entity_registry.async_get(SWITCH_ENTITY_ID) is not None + count = len(hass.states.async_entity_ids(SWITCH_DOMAIN)) + + _add_relay_frame(ufp, relay) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == count + assert "already exists" not in caplog.text + + +async def test_relay_switch_hybrid_startup_relay_not_added_twice( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + ufp_with_relay: tuple[MockUFPFixture, Mock], +) -> None: + """A relay enumerated at hybrid setup is in the add baseline. + + Its add frame (or the re-offer after a reconnect) must not create the + switches a second time. + """ + ufp, relay = ufp_with_relay + await init_entry(hass, ufp, []) + count = len(hass.states.async_entity_ids(SWITCH_DOMAIN)) + assert count + + _add_relay_frame(ufp, relay) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == count + assert "already exists" not in caplog.text diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py index 535dd9b06e6f55..b92133b5a52743 100644 --- a/tests/components/unifiprotect/test_select.py +++ b/tests/components/unifiprotect/test_select.py @@ -2,7 +2,7 @@ from copy import copy from typing import Any -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch import pytest from uiprotect.data import ( @@ -20,7 +20,6 @@ NvrArmMode, NvrArmModeStatus, PTZPatrol, - PublicBootstrap, PublicHdrMode, RecordingMode, Viewer, @@ -56,6 +55,7 @@ assert_entity_counts, ids_from_device_description, init_entry, + make_public_bootstrap, make_public_camera, make_public_light, public_device_ws_message, @@ -429,9 +429,11 @@ async def test_select_set_option_light_motion( hass, Platform.SELECT, light, LIGHT_SELECTS[0] ) - with patch_ufp_method( - light, "set_light_mode_public", new_callable=AsyncMock - ) as mock_method: + public = make_public_light(light) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + with patch.object(public, "set_light_mode", new_callable=AsyncMock) as mock_method: await hass.services.async_call( "select", "select_option", @@ -740,9 +742,11 @@ async def test_select_set_option_camera_hdr_mode( hass, Platform.SELECT, doorbell, description ) - with patch_ufp_method( - doorbell, "set_hdr_mode_public", new_callable=AsyncMock - ) as mock_method: + public = make_public_camera(doorbell) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + with patch.object(public, "set_hdr_mode", new_callable=AsyncMock) as mock_method: await hass.services.async_call( "select", "select_option", @@ -1006,13 +1010,7 @@ def _make_nvr_arm_mode(profile_id: str | None = None) -> Mock: def _make_public_bootstrap(arm_mode: Mock | None, profiles: dict[str, Mock]) -> Mock: """Create a PublicBootstrap mock with arm profiles for testing.""" - pb = Mock(spec=PublicBootstrap) - pb.arm_mode = arm_mode - pb.arm_profiles = profiles - pb.relays = {} - pb.sirens = {} - pb.fobs = {} - return pb + return make_public_bootstrap(arm_mode=arm_mode, arm_profiles=profiles) async def test_select_nvr_arm_profile_not_created_without_public_bootstrap( diff --git a/tests/components/unifiprotect/test_siren.py b/tests/components/unifiprotect/test_siren.py index aca926ff5e4982..dc16ef132eef0c 100644 --- a/tests/components/unifiprotect/test_siren.py +++ b/tests/components/unifiprotect/test_siren.py @@ -4,13 +4,7 @@ from unittest.mock import AsyncMock, Mock import pytest -from uiprotect.data import ( - ModelType, - PublicBootstrap, - PublicSirenStatus, - Siren, - SirenDuration, -) +from uiprotect.data import ModelType, PublicSirenStatus, Siren, SirenDuration, WSAction from uiprotect.exceptions import ClientError, NotAuthorized from uiprotect.websocket import WebsocketState @@ -34,7 +28,12 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import dt as dt_util -from .utils import MockUFPFixture, assert_entity_counts, init_entry +from .utils import ( + MockUFPFixture, + assert_entity_counts, + init_entry, + make_public_bootstrap, +) from tests.common import async_fire_time_changed @@ -68,13 +67,7 @@ def _make_siren(*, is_active: bool = False) -> Mock: def _make_public_bootstrap(siren: Mock | None) -> Mock: """Build a public bootstrap mock with the given siren.""" - pb = Mock(spec=PublicBootstrap) - pb.sirens = {siren.id: siren} if siren is not None else {} - pb.relays = {} - pb.arm_mode = None - pb.arm_profiles = {} - pb.fobs = {} - return pb + return make_public_bootstrap(sirens={siren.id: siren} if siren is not None else {}) def _make_ws_msg(siren: Mock, *, deleted: bool = False) -> Mock: @@ -673,3 +666,32 @@ async def test_siren_auto_off_timer_scheduled_at_startup( state = hass.states.get(SIREN_ENTITY_ID) assert state is not None assert state.state == STATE_OFF + + +async def test_siren_added_after_setup_in_hybrid( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + siren: Mock, +) -> None: + """A siren adopted after setup gets its entity in hybrid mode too. + + The private bootstrap has no store for sirens, so the adopt path never + sees one; discovery goes through the public add signal in both modes. + """ + ufp.api.has_public_bootstrap = True + pb = _make_public_bootstrap(None) + ufp.api.public_bootstrap = pb + ufp.api.update_public = AsyncMock(return_value=pb) + + await init_entry(hass, ufp, []) + assert entity_registry.async_get(SIREN_ENTITY_ID) is None + + pb.sirens = {siren.id: siren} + msg = _make_ws_msg(siren) + msg.action = WSAction.ADD + assert ufp.devices_ws_subscription is not None + ufp.devices_ws_subscription(msg) + await hass.async_block_till_done() + + assert entity_registry.async_get(SIREN_ENTITY_ID) is not None diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index f7f822016d3b88..5cc9468e34ba02 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -1,7 +1,9 @@ """Test the UniFi Protect switch platform.""" +from collections.abc import Callable, Coroutine +from functools import partial from typing import Any -from unittest.mock import AsyncMock, Mock, call +from unittest.mock import AsyncMock, Mock, call, patch import pytest from uiprotect.data import ( @@ -14,6 +16,7 @@ SmartDetectAudioType, SmartDetectObjectType, VideoMode, + WSAction, ) from uiprotect.data.public_devices import SensorFeatureCapability from uiprotect.exceptions import ClientError, NotAuthorized @@ -28,6 +31,7 @@ SENSE_SWITCHES, ProtectSwitchEntityDescription, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_ENTITY_ID, @@ -38,9 +42,11 @@ ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity_platform import async_get_platforms from . import patch_ufp_method +from .conftest import UNIFI_MAC from .utils import ( MockUFPFixture, adopt_devices, @@ -78,6 +84,8 @@ for d in CAMERA_SWITCHES_BASIC if d.key not in ("high_fps", "privacy_mode", "hdr_mode") ] +CAMERA_SWITCHES_PRIVATE = [d for d in CAMERA_SWITCHES_NO_EXTRA if not d.is_public_value] +CAMERA_SWITCHES_PUBLIC = [d for d in CAMERA_SWITCHES_NO_EXTRA if d.is_public_value] async def test_switch_camera_remove( @@ -300,8 +308,12 @@ async def test_switch_light_status( hass, Platform.SWITCH, light, description ) - with patch_ufp_method( - light, "set_status_light_public", new_callable=AsyncMock + public = make_public_light(light) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + with patch.object( + public, "set_status_light", new_callable=AsyncMock ) as mock_method: await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -379,14 +391,14 @@ async def test_switch_camera_ssh( mock_method.assert_called_with(False) -@pytest.mark.parametrize("description", CAMERA_SWITCHES_NO_EXTRA) +@pytest.mark.parametrize("description", CAMERA_SWITCHES_PRIVATE) async def test_switch_camera_simple( hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, description: ProtectSwitchEntityDescription, ) -> None: - """Tests all simple switches for cameras.""" + """Tests the private-API camera switches.""" setup_public_camera(ufp) await init_entry(hass, ufp, [doorbell]) @@ -414,6 +426,44 @@ async def test_switch_camera_simple( mock_method.assert_called_with(False) +@pytest.mark.parametrize("description", CAMERA_SWITCHES_PUBLIC) +async def test_switch_camera_simple_public( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + description: ProtectSwitchEntityDescription, +) -> None: + """The migrated camera switches write through the public object.""" + + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell]) + + assert description.ufp_set_method is not None + + public = make_public_camera(doorbell) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + with patch.object( + public, description.ufp_set_method, new_callable=AsyncMock + ) as mock_method: + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) + + await hass.services.async_call( + "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + mock_method.assert_called_once_with(True) + + await hass.services.async_call( + "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + mock_method.assert_called_with(False) + + async def test_switch_camera_highfps( hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ) -> None: @@ -429,9 +479,11 @@ async def test_switch_camera_highfps( hass, Platform.SWITCH, doorbell, description ) - with patch_ufp_method( - doorbell, "set_video_mode_public", new_callable=AsyncMock - ) as mock_method: + public = make_public_camera(doorbell) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + with patch.object(public, "set_video_mode", new_callable=AsyncMock) as mock_method: await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True ) @@ -522,14 +574,18 @@ async def test_switch_camera_detections_public_api( await init_entry(hass, ufp, [doorbell]) assert description.ufp_set_method is not None - assert description.ufp_set_method.endswith("_public") + assert description.ufp_capability is not None _, entity_id = await ids_from_device_description( hass, Platform.SWITCH, doorbell, description ) - with patch_ufp_method( - doorbell, description.ufp_set_method, new_callable=AsyncMock + public = make_public_camera(doorbell) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + with patch.object( + public, description.ufp_set_method, new_callable=AsyncMock ) as mock_method: await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -836,10 +892,14 @@ async def test_switch_turn_on_client_error( hass, Platform.SWITCH, light, description ) + public = make_public_light(light) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + with ( - patch_ufp_method( - light, - "set_status_light_public", + patch.object( + public, + "set_status_light", new_callable=AsyncMock, side_effect=ClientError("Test error"), ), @@ -864,10 +924,14 @@ async def test_switch_turn_on_not_authorized( hass, Platform.SWITCH, light, description ) + public = make_public_light(light) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + with ( - patch_ufp_method( - light, - "set_status_light_public", + patch.object( + public, + "set_status_light", new_callable=AsyncMock, side_effect=NotAuthorized("Not authorized"), ), @@ -975,11 +1039,11 @@ async def test_switch_sense_no_capability_map_keeps_existing( # The five sense settings the public API exposes, with the public-mock override # that flips them and the public setter each switch must write through. MIGRATED_SENSE_SWITCHES = [ - ("motion", "motion_enabled", "set_motion_status_public"), - ("temperature", "temperature_enabled", "set_temperature_status_public"), - ("humidity", "humidity_enabled", "set_humidity_status_public"), - ("light", "light_enabled", "set_light_status_public"), - ("alarm", "alarm_enabled", "set_alarm_public"), + ("motion", "motion_enabled", "set_motion_status"), + ("temperature", "temperature_enabled", "set_temperature_status"), + ("humidity", "humidity_enabled", "set_humidity_status"), + ("light", "light_enabled", "set_light_status"), + ("alarm", "alarm_enabled", "set_alarm"), ] @@ -1029,9 +1093,11 @@ async def test_switch_sense_set_public( hass, Platform.SWITCH, sensor_all, description ) - with patch_ufp_method( - sensor_all, set_method, new_callable=AsyncMock - ) as mock_method: + public = make_public_sensor(sensor_all) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + with patch.object(public, set_method, new_callable=AsyncMock) as mock_method: await hass.services.async_call( "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True ) @@ -1105,3 +1171,303 @@ async def test_switch_sense_public_switches_ignore_local_permissions( hass, Platform.SWITCH, sensor_all, description ) assert entity_registry.async_get(entity_id) is None + + +_SMART_KEYS = {key for key, _, _ in CAMERA_SWITCHES_DETECTION_READ} + + +def _switch_keys(entity_registry: er.EntityRegistry, mac: str) -> set[str]: + """Return the description keys of the switches registered for a device.""" + prefix = f"{mac}_" + return { + entry.unique_id.removeprefix(prefix) + for entry in entity_registry.entities.values() + if entry.domain == Platform.SWITCH and entry.unique_id.startswith(prefix) + } + + +def _make_streamless_public_camera(camera: Camera) -> Mock: + """Build a public camera without RTSPS streams (snapshot-only).""" + public = make_public_camera(camera) + public.rtsps_streams = None + return public + + +@pytest.mark.parametrize( + ("key", "object_types", "audio_types"), CAMERA_SWITCHES_DETECTION_READ +) +async def test_switch_camera_detection_capability_gating( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + doorbell: Camera, + key: str, + object_types: list[SmartDetectObjectType], + audio_types: list[SmartDetectAudioType], +) -> None: + """A detection switch exists only for a capability the camera advertises.""" + doorbell.feature_flags.smart_detect_types = object_types + doorbell.feature_flags.smart_detect_audio_types = audio_types + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell]) + + assert _switch_keys(entity_registry, doorbell.mac) & _SMART_KEYS == {key} + + +async def test_switch_command_when_public_object_vanishes( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + sensor_all: Sensor, + ufp_public_only: MockUFPFixture, + setup_public_only: Callable[[], Coroutine[Any, Any, None]], +) -> None: + """A switch deleted mid-call raises a translated error, not AttributeError. + + Service calls filter unavailable entities once up front and then run the + entity coroutines, so a delete frame landing after that check must not + reach the command path as a missing public object. + """ + public = make_public_sensor( + sensor_all, capabilities={SensorFeatureCapability.MOTION} + ) + pb = ufp_public_only.api.public_bootstrap + pb.sensors = {public.id: public} + + await setup_public_only() + entity_id = entity_registry.async_get_entity_id( + Platform.SWITCH, DOMAIN, f"{public.mac}_motion" + ) + assert entity_id + platform = next( + p for p in async_get_platforms(hass, DOMAIN) if p.domain == Platform.SWITCH + ) + entity = platform.entities[entity_id] + request_call = entity.async_request_call + + async def _delete_then_run(coro: Coroutine[Any, Any, Any]) -> Any: + """Drop the sensor after the availability filter, before the command.""" + pb.sensors.pop(public.id) + msg = public_device_ws_message(public) + msg.new_obj = None + msg.old_obj = public + ufp_public_only.devices_ws_subscription(msg) + return await request_call(coro) + + with ( + patch.object(entity, "async_request_call", _delete_then_run), + pytest.raises(HomeAssistantError) as err, + ): + await hass.services.async_call( + "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + public.set_motion_status.assert_not_called() + assert err.value.translation_domain == DOMAIN + assert err.value.translation_key == "device_not_available" + assert err.value.translation_placeholders == {"device_name": public.display_name} + + +async def test_switch_hybrid_public_sensor_without_private_deferred( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + sensor_all: Sensor, +) -> None: + """Hybrid leaves a public sensor without a private object to the adopt path. + + It gets no entities from the public object alone, and the capability + cleanup does not touch its registry entries either. + """ + setup_public_sensor(ufp, capabilities=_ENV_CAPABILITIES) + orphan = make_public_sensor(sensor_all, capabilities=_ENV_CAPABILITIES) + orphan.id = "orphan-sensor" + orphan.mac = "FFEEDDCCBB03" + ufp.api.public_bootstrap.sensors[orphan.id] = orphan + stale = entity_registry.async_get_or_create( + Platform.SWITCH, DOMAIN, f"{orphan.mac}_motion", config_entry=ufp.entry + ) + + await init_entry(hass, ufp, []) + + assert _switch_keys(entity_registry, orphan.mac) == {"motion"} + assert entity_registry.async_get(stale.entity_id) is not None + + +@pytest.mark.parametrize( + ("fixture_name", "make", "key", "setter", "absent_keys"), + [ + pytest.param( + "doorbell", + _make_streamless_public_camera, + "smart_person", + "set_person_detection", + {"ssh", "motion", "high_fps", "privacy_mode", "color_night_vision"}, + id="camera", + ), + pytest.param( + "sensor_all", + partial( + make_public_sensor, + motion_enabled=True, + capabilities={SensorFeatureCapability.MOTION}, + ), + "motion", + "set_motion_status", + {"status_light", "temperature"}, + id="sensor", + ), + pytest.param( + "light", + partial(make_public_light, is_indicator_enabled=True), + "status_light", + "set_status_light", + {"ssh"}, + id="light", + ), + ], +) +async def test_public_only_switch_end_to_end( + hass: HomeAssistant, + request: pytest.FixtureRequest, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + ufp_public_only: MockUFPFixture, + setup_public_only: Callable[[], Coroutine[Any, Any, None]], + fixture_name: str, + make: Callable[[Any], Mock], + key: str, + setter: str, + absent_keys: set[str], +) -> None: + """A public-only entry builds the migrated switches from the public object. + + Private-only switches and the NVR switches are absent, the device is + registered from public identity and commands go to the public setter. + """ + device = request.getfixturevalue(fixture_name) + public = make(device) + store = getattr(ufp_public_only.api.public_bootstrap, f"{device.model.value}s") + store[device.id] = public + + await setup_public_only() + + assert ufp_public_only.entry.state is ConfigEntryState.LOADED + keys = _switch_keys(entity_registry, device.mac) + assert key in keys + assert not keys & absent_keys + assert hass.states.get("switch.unifiprotect_insights_enabled") is None + + entity_id = entity_registry.async_get_entity_id( + Platform.SWITCH, DOMAIN, f"{device.mac}_{key}" + ) + assert entity_id + assert hass.states.get(entity_id).state == STATE_ON + + entry = entity_registry.async_get(entity_id) + assert entry + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.model == public.type + nvr_device = device_registry.async_get_device_by_identifier( + (DOMAIN, UNIFI_MAC), ufp_public_only.entry.entry_id + ) + assert nvr_device + assert device_entry.via_device_id == nvr_device.id + + await hass.services.async_call( + "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + getattr(public, setter).assert_awaited_once_with(False) + + +async def test_public_only_switch_camera_capability_gating( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + doorbell: Camera, + ufp_public_only: MockUFPFixture, + setup_public_only: Callable[[], Coroutine[Any, Any, None]], +) -> None: + """Without a private object the detection switches gate on the public capability.""" + doorbell.feature_flags.smart_detect_types = [SmartDetectObjectType.PERSON] + doorbell.feature_flags.smart_detect_audio_types = [] + public = _make_streamless_public_camera(doorbell) + ufp_public_only.api.public_bootstrap.cameras[doorbell.id] = public + + await setup_public_only() + + assert _switch_keys(entity_registry, doorbell.mac) & _SMART_KEYS == {"smart_person"} + + +@pytest.mark.parametrize( + ("fixture_name", "make", "key"), + [ + pytest.param( + "sensor_all", + partial(make_public_sensor, capabilities={SensorFeatureCapability.MOTION}), + "motion", + id="sensor", + ), + pytest.param( + "doorbell", _make_streamless_public_camera, "smart_person", id="camera" + ), + ], +) +async def test_public_only_switch_added_after_setup( + hass: HomeAssistant, + request: pytest.FixtureRequest, + entity_registry: er.EntityRegistry, + ufp_public_only: MockUFPFixture, + setup_public_only: Callable[[], Coroutine[Any, Any, None]], + caplog: pytest.LogCaptureFixture, + fixture_name: str, + make: Callable[[Any], Mock], + key: str, +) -> None: + """In public-only mode a device added later gets its switches from its add frame. + + The public devices websocket ``add`` frame is the only discovery signal + without a local user; a re-delivered frame must not add a second time. + """ + await setup_public_only() + assert_entity_counts(hass, Platform.SWITCH, 0, 0) + + device = request.getfixturevalue(fixture_name) + public = make(device) + store = getattr(ufp_public_only.api.public_bootstrap, f"{device.model.value}s") + store[device.id] = public + msg = public_device_ws_message(public) + msg.action = WSAction.ADD + ufp_public_only.devices_ws_subscription(msg) + await hass.async_block_till_done() + + assert key in _switch_keys(entity_registry, device.mac) + count = len(hass.states.async_entity_ids(Platform.SWITCH.value)) + + ufp_public_only.devices_ws_subscription(msg) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids(Platform.SWITCH.value)) == count + assert "already exists" not in caplog.text + + +async def test_public_only_switch_sense_registry_cleanup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + sensor_all: Sensor, + ufp_public_only: MockUFPFixture, + setup_public_only: Callable[[], Coroutine[Any, Any, None]], +) -> None: + """The capability cleanup runs without a private bootstrap.""" + stale = entity_registry.async_get_or_create( + Platform.SWITCH, + DOMAIN, + f"{sensor_all.mac}_temperature", + config_entry=ufp_public_only.entry, + ) + ufp_public_only.api.public_bootstrap.sensors[sensor_all.id] = make_public_sensor( + sensor_all, capabilities={SensorFeatureCapability.MOTION} + ) + + await setup_public_only() + + assert entity_registry.async_get(stale.entity_id) is None diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 2d1f6dfc2d02f8..79c350e76e8e15 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -1,9 +1,11 @@ """Test helpers for UniFi Protect.""" -from collections.abc import Callable, Sequence +from collections.abc import Callable, Iterator, Sequence from dataclasses import dataclass from datetime import timedelta -from unittest.mock import Mock +from functools import partial +from typing import Any +from unittest.mock import AsyncMock, Mock from uiprotect import EventChange, ProtectApiClient, ProtectEvent from uiprotect.api import RTSPSStreams @@ -31,6 +33,7 @@ from uiprotect.data.bootstrap import ProtectDeviceRef from uiprotect.data.public_devices import ( PublicCamera, + PublicCameraFeatureFlags, PublicCameraLedSettings, PublicHdrMode, PublicLight, @@ -260,6 +263,77 @@ def public_rtsps_for(camera: Camera) -> RTSPSStreams: return RTSPSStreams(**urls) +_PUBLIC_STORE_ATTRS: dict[ModelType, str] = { + ModelType.CAMERA: "cameras", + ModelType.LIGHT: "lights", + ModelType.SENSOR: "sensors", + ModelType.CHIME: "chimes", + ModelType.VIEWPORT: "viewers", + ModelType.FOB: "fobs", +} + + +def make_public_bootstrap(**attrs: Any) -> Mock: + """Build a ``PublicBootstrap`` mock backed by per-family device maps. + + ``store_for``/``get``/``all_devices`` read the maps at call time, so a test + may replace a whole map after setup (``pb.lights = {...}``). + """ + pb = Mock(spec=PublicBootstrap) + for attr in (*_PUBLIC_STORE_ATTRS.values(), "relays", "sirens", "arm_profiles"): + setattr(pb, attr, {}) + pb.arm_mode = None + pb.nvr = None + for attr, value in attrs.items(): + setattr(pb, attr, value) + + def _store_for(model: ModelType) -> dict[str, Any] | None: + if (attr := _PUBLIC_STORE_ATTRS.get(model)) is None: + return None + return getattr(pb, attr) + + def _get(model: ModelType, obj_id: str) -> Any: + store = _store_for(model) + return None if store is None else store.get(obj_id) + + def _all_devices(*, include_nvr: bool = False) -> Iterator[Any]: + if include_nvr and pb.nvr is not None: + yield pb.nvr + for attr in (*_PUBLIC_STORE_ATTRS.values(), "relays", "sirens"): + yield from getattr(pb, attr).values() + + pb.store_for = Mock(side_effect=_store_for) + pb.get = Mock(side_effect=_get) + pb.all_devices = _all_devices + return pb + + +def _mirror_on_update_public( + ufp: MockUFPFixture, + private_store_attr: str, + store: dict[str, Any], + make: Callable[[Any], Mock], + *, + keep_existing: bool = False, +) -> None: + """Fill ``store`` from the private bootstrap whenever ``update_public`` runs. + + Mirrors the library prime so enumeration (which reads the store) sees the + public objects; lookups keep mirroring lazily for devices adopted later. + """ + previous = ufp.api.update_public + + async def _update_public(*args: Any, **kwargs: Any) -> Any: + result = await previous(*args, **kwargs) + for obj_id, private in getattr(ufp.api.bootstrap, private_store_attr).items(): + if keep_existing and obj_id in store: + continue + store[obj_id] = make(private) + return result + + ufp.api.update_public = AsyncMock(side_effect=_update_public) + + def make_public_sensor( sensor: Sensor, *, @@ -295,6 +369,9 @@ def make_public_sensor( public = Mock(spec=PublicSensor) public.id = sensor.id public.mac = sensor.mac + public.name = sensor.name + public.display_name = sensor.display_name + public.type = sensor.type public.model = ModelType.SENSOR public.state = DeviceState[sensor.state.name] if state is None else state public.mount_type = sensor.mount_type if mount_type is None else mount_type @@ -560,11 +637,22 @@ def make_public_camera( if hdr_type is None else hdr_type ) - public.has_package_camera = camera.feature_flags.has_package_camera - public.feature_flags = Mock() - public.feature_flags.support_full_hd_snapshot = ( - camera.feature_flags.support_full_hd_snapshot + flags = camera.feature_flags + public.has_package_camera = flags.has_package_camera + # Spec'd so a private-only flag (e.g. ``has_highfps``) reads as absent. + public.feature_flags = Mock(spec=PublicCameraFeatureFlags) + public.feature_flags.support_full_hd_snapshot = flags.support_full_hd_snapshot + public.feature_flags.has_hdr = flags.has_hdr + public.feature_flags.has_mic = flags.has_mic + public.feature_flags.has_led_status = flags.has_led_status + public.feature_flags.has_speaker = flags.has_speaker + public.feature_flags.video_modes = list(flags.video_modes) + public.feature_flags.smart_detect_types = list(flags.smart_detect_types) + public.feature_flags.smart_detect_audio_types = list( + flags.smart_detect_audio_types or [] ) + # The capability gate runs the library's own logic on the mirrored flags. + public.can_detect = Mock(side_effect=partial(PublicCamera.can_detect, public)) qualities = [ChannelQuality.HIGH, ChannelQuality.MEDIUM, ChannelQuality.LOW] if public.has_package_camera: qualities.append(ChannelQuality.PACKAGE) @@ -584,26 +672,19 @@ def setup_public_sensor( newer firmware with a capability map. """ public_bootstrap = PublicBootstrap() - pb = Mock(spec=PublicBootstrap) - pb.sensors = public_bootstrap.sensors - pb.relays = {} - pb.sirens = {} - pb.fobs = {} - pb.arm_mode = None - pb.arm_profiles = {} + pb = make_public_bootstrap(sensors=public_bootstrap.sensors) + make = partial(make_public_sensor, capabilities=capabilities) def _get(model: ModelType, obj_id: str) -> ProtectModelWithId | None: if ( model is ModelType.SENSOR and (private := ufp.api.bootstrap.sensors.get(obj_id)) is not None ): - public_bootstrap.sensors[obj_id] = make_public_sensor( - private, capabilities=capabilities - ) + public_bootstrap.sensors[obj_id] = make(private) return public_bootstrap.get(model, obj_id) pb.get = _get - pb.all_devices = public_bootstrap.all_devices + _mirror_on_update_public(ufp, "sensors", public_bootstrap.sensors, make) ufp.api.has_public_bootstrap = True ufp.api.public_bootstrap = pb @@ -615,13 +696,7 @@ def setup_public_light(ufp: MockUFPFixture) -> None: FloodLight duration number reads from the public object. """ public_bootstrap = PublicBootstrap() - pb = Mock(spec=PublicBootstrap) - pb.lights = public_bootstrap.lights - pb.relays = {} - pb.sirens = {} - pb.fobs = {} - pb.arm_mode = None - pb.arm_profiles = {} + pb = make_public_bootstrap(lights=public_bootstrap.lights) def _get(model: ModelType, obj_id: str) -> ProtectModelWithId | None: # One mock per id so command assertions hit the entity's cached object. @@ -634,7 +709,9 @@ def _get(model: ModelType, obj_id: str) -> ProtectModelWithId | None: return public_bootstrap.get(model, obj_id) pb.get = _get - pb.all_devices = public_bootstrap.all_devices + _mirror_on_update_public( + ufp, "lights", public_bootstrap.lights, make_public_light, keep_existing=True + ) ufp.api.has_public_bootstrap = True ufp.api.public_bootstrap = pb @@ -646,13 +723,7 @@ def setup_public_camera(ufp: MockUFPFixture) -> None: camera config entities read from the public object. """ public_bootstrap = PublicBootstrap() - pb = Mock(spec=PublicBootstrap) - pb.cameras = public_bootstrap.cameras - pb.relays = {} - pb.sirens = {} - pb.fobs = {} - pb.arm_mode = None - pb.arm_profiles = {} + pb = make_public_bootstrap(cameras=public_bootstrap.cameras) def _get(model: ModelType, obj_id: str) -> ProtectModelWithId | None: if ( @@ -663,7 +734,6 @@ def _get(model: ModelType, obj_id: str) -> ProtectModelWithId | None: return public_bootstrap.get(model, obj_id) pb.get = _get - pb.all_devices = public_bootstrap.all_devices ufp.api.has_public_bootstrap = True ufp.api.public_bootstrap = pb From 51c5e9c3aae0726f7255e045baf137d2416ff93b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 18 Sep 2026 16:55:12 +0200 Subject: [PATCH 17/26] Turn off Tuya fans when the speed is set to 0% (#181972) --- homeassistant/components/tuya/fan.py | 5 +++++ tests/components/tuya/test_fan.py | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index f979390ce2d695..d1701593733255 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -121,6 +121,11 @@ async def async_set_direction(self, direction: str) -> None: @override async def async_set_percentage(self, percentage: int) -> None: """Set the speed of the fan, as a percentage.""" + # The speed wrappers have no off position, 0% has to hit the switch + if percentage == 0 and self._switch_wrapper is not None: + await self.async_turn_off() + return + await self._async_send_wrapper_updates(self._speed_wrapper, percentage) @override diff --git a/tests/components/tuya/test_fan.py b/tests/components/tuya/test_fan.py index f2c15d164bf452..42c0b7b4b4c523 100644 --- a/tests/components/tuya/test_fan.py +++ b/tests/components/tuya/test_fan.py @@ -11,6 +11,7 @@ DOMAIN as FAN_DOMAIN, SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, + SERVICE_SET_PERCENTAGE, SERVICE_SET_PRESET_MODE, SERVICE_TURN_OFF, SERVICE_TURN_ON, @@ -91,6 +92,27 @@ async def test_platform_setup_and_discovery( {"preset_mode": "sleep"}, [{"code": "switch", "value": True}, {"code": "mode", "value": "sleep"}], ), + ( + "fs_g0ewlb1vmwqljzji", + "fan.ceiling_fan_with_light", + SERVICE_SET_PERCENTAGE, + {"percentage": 50}, + [{"code": "fan_speed", "value": "3"}], + ), + ( + "fs_g0ewlb1vmwqljzji", + "fan.ceiling_fan_with_light", + SERVICE_SET_PERCENTAGE, + {"percentage": 0}, + [{"code": "switch", "value": False}], + ), + ( + "ks_j9fa8ahzac8uvlfl", + "fan.tower_fan_ca_407g_smart", + SERVICE_SET_PERCENTAGE, + {"percentage": 0}, + [{"code": "switch", "value": False}], + ), ], ) async def test_action( From 988d09b6a3cb3e36435917064709ae5c4113fb71 Mon Sep 17 00:00:00 2001 From: Yoav Mor Date: Fri, 18 Sep 2026 17:55:21 +0300 Subject: [PATCH 18/26] Bump besen to 0.4.7 (#182596) --- homeassistant/components/besen/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/besen/manifest.json b/homeassistant/components/besen/manifest.json index 6a631ae376a52c..762872e2d77bbc 100644 --- a/homeassistant/components/besen/manifest.json +++ b/homeassistant/components/besen/manifest.json @@ -14,5 +14,5 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["besen==0.4.6"] + "requirements": ["besen==0.4.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index e51dbc87caaaca..f659697ed1aaa5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -676,7 +676,7 @@ batinfo==0.4.2 beautifulsoup4==4.13.3 # homeassistant.components.besen -besen==0.4.6 +besen==0.4.7 # homeassistant.components.bitvis bitvis-protobuf==2.0.4 From ff59823e3c9a77effa2b47a442a5ba2a550f3311 Mon Sep 17 00:00:00 2001 From: Anthony <210036686+Herbertmt978@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:57:42 +0100 Subject: [PATCH 19/26] Clarify Axle Energy API error messages (#182569) --- homeassistant/components/axle_energy/config_flow.py | 11 +++++++++-- homeassistant/components/axle_energy/strings.json | 1 + tests/components/axle_energy/test_config_flow.py | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/axle_energy/config_flow.py b/homeassistant/components/axle_energy/config_flow.py index 2d2772f385afd7..c547be55654152 100644 --- a/homeassistant/components/axle_energy/config_flow.py +++ b/homeassistant/components/axle_energy/config_flow.py @@ -2,7 +2,12 @@ from typing import Any, override -from aioaxlevpp import AxleAuthenticationError, AxleClient, AxleError +from aioaxlevpp import ( + AxleAuthenticationError, + AxleClient, + AxleConnectionError, + AxleError, +) import probatio from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -37,8 +42,10 @@ async def _validate(self, user_input: dict[str, Any]) -> dict[str, str]: await client.get_event() except AxleAuthenticationError: return {"base": "invalid_auth"} - except AxleError: + except AxleConnectionError: return {"base": "cannot_connect"} + except AxleError: + return {"base": "cannot_retrieve"} return {} @override diff --git a/homeassistant/components/axle_energy/strings.json b/homeassistant/components/axle_energy/strings.json index 79e5dd84542d83..468bff368d3064 100644 --- a/homeassistant/components/axle_energy/strings.json +++ b/homeassistant/components/axle_energy/strings.json @@ -5,6 +5,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_retrieve": "[%key:component::axle_energy::exceptions::cannot_connect::message%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "step": { diff --git a/tests/components/axle_energy/test_config_flow.py b/tests/components/axle_energy/test_config_flow.py index 1faf6ecdf08e18..aad671f8372d34 100644 --- a/tests/components/axle_energy/test_config_flow.py +++ b/tests/components/axle_energy/test_config_flow.py @@ -36,7 +36,7 @@ async def test_user(hass: HomeAssistant) -> None: [ (AxleAuthenticationError(), "invalid_auth"), (AxleConnectionError(), "cannot_connect"), - (AxleError(), "cannot_connect"), + (AxleError(), "cannot_retrieve"), ], ) async def test_user_errors( From 6177c711d2b39635803ee6abac146e7d23e20e99 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 18 Sep 2026 11:20:13 -0400 Subject: [PATCH 20/26] Use ToolResult in litellm (#182546) Co-authored-by: Claude --- homeassistant/components/litellm/entity.py | 4 +++- tests/components/litellm/snapshots/test_conversation.ambr | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litellm/entity.py b/homeassistant/components/litellm/entity.py index 61b34ffc956a9e..aa7a542c805ed1 100644 --- a/homeassistant/components/litellm/entity.py +++ b/homeassistant/components/litellm/entity.py @@ -60,7 +60,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 diff --git a/tests/components/litellm/snapshots/test_conversation.ambr b/tests/components/litellm/snapshots/test_conversation.ambr index 946c7232c8d29e..850a13e13af4da 100644 --- a/tests/components/litellm/snapshots/test_conversation.ambr +++ b/tests/components/litellm/snapshots/test_conversation.ambr @@ -262,7 +262,7 @@ ]), }), dict({ - 'content': '{"speech":{"plain":{"speech":"12:00 PM","extra_data":null}},"response_type":"action_done","speech_slots":{"time":"12:00:00"},"data":{"success":[],"failed":[]}}', + 'content': '{"data":{"speech":{"plain":{"speech":"12:00 PM","extra_data":null}},"response_type":"action_done","speech_slots":{"time":"12:00:00"},"data":{"success":[],"failed":[]}},"error":false}', 'role': 'tool', 'tool_call_id': 'mock_tool_call_id', }), @@ -289,7 +289,7 @@ ]), }), dict({ - 'content': '"value1"', + 'content': '{"data":"value1","error":false}', 'role': 'tool', 'tool_call_id': 'call_call_1', }), From 621bec37a101e9ad274ceaf3d85f4de8d157f581 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 18 Sep 2026 11:21:52 -0400 Subject: [PATCH 21/26] Use ToolResult in openai_conversation (#182544) Co-authored-by: Claude --- .../components/openai_conversation/entity.py | 31 +++++++++++++------ .../snapshots/test_conversation.ambr | 8 ++--- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index df599ed68894c5..503125179d926c 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -192,7 +192,7 @@ def _convert_content_to_param( and content.tool_call_id in web_search_calls ): web_search_call = web_search_calls.pop(content.tool_call_id) - web_search_call["status"] = content.tool_result.get( # type: ignore[typeddict-item] + web_search_call["status"] = content.result.data.get( # type: ignore[typeddict-item] "status", "completed" ) messages.append(web_search_call) @@ -201,7 +201,12 @@ def _convert_content_to_param( FunctionCallOutput( type="function_call_output", call_id=content.tool_call_id, - output=json_dumps(content.tool_result), + output=json_dumps( + { + "data": content.result.data, + "error": content.result.error, + } + ), ) ) continue @@ -342,13 +347,16 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have "role": "tool_result", "tool_call_id": event.item.id, "tool_name": "code_interpreter", - "tool_result": { - "output": ( - [output.to_dict() for output in event.item.outputs] # type: ignore[misc] - if event.item.outputs is not None - else None - ) - }, + "result": llm.ToolResult( + data={ + "output": ( + [output.to_dict() for output in event.item.outputs] # type: ignore[misc] + if event.item.outputs is not None + else None + ) + }, + error=event.item.status == "failed", + ), } last_role = "tool_result" elif isinstance(event.item, ResponseFunctionWebSearch): @@ -370,7 +378,10 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have "role": "tool_result", "tool_call_id": event.item.id, "tool_name": "web_search_call", - "tool_result": {"status": event.item.status}, + "result": llm.ToolResult( + data={"status": event.item.status}, + error=event.item.status == "failed", + ), } last_role = "tool_result" elif isinstance(event.item, ImageGenerationCall): diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index 11e7ad1f0848b8..8d152c2aec524a 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -14,7 +14,7 @@ }), dict({ 'call_id': 'ci_A', - 'output': '{"output":[{"logs":"235.70108188126758\\n","type":"logs"}]}', + 'output': '{"data":{"output":[{"logs":"235.70108188126758\\n","type":"logs"}]},"error":false}', 'type': 'function_call_output', }), dict({ @@ -204,7 +204,7 @@ }), dict({ 'call_id': 'mock-tool-call-id', - 'output': '{"speech":{"plain":{"speech":"12:00 PM","extra_data":null}},"response_type":"action_done","speech_slots":{"time":"12:00:00"},"data":{"success":[],"failed":[]}}', + 'output': '{"data":{"speech":{"plain":{"speech":"12:00 PM","extra_data":null}},"response_type":"action_done","speech_slots":{"time":"12:00:00"},"data":{"success":[],"failed":[]}},"error":false}', 'type': 'function_call_output', }), dict({ @@ -240,7 +240,7 @@ }), dict({ 'call_id': 'call_call_1', - 'output': '"value1"', + 'output': '{"data":"value1","error":false}', 'type': 'function_call_output', }), dict({ @@ -251,7 +251,7 @@ }), dict({ 'call_id': 'call_call_2', - 'output': '"value2"', + 'output': '{"data":"value2","error":false}', 'type': 'function_call_output', }), dict({ From 1ad01fb52681593a1d1d2532313662d43256ffec Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 18 Sep 2026 11:22:25 -0400 Subject: [PATCH 22/26] Use ToolResult in cloud (#182543) Co-authored-by: Claude --- homeassistant/components/cloud/entity.py | 14 +++++++-- tests/components/cloud/test_entity.py | 37 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cloud/entity.py b/homeassistant/components/cloud/entity.py index 2c7fcf2fe24415..ab24892a905618 100644 --- a/homeassistant/components/cloud/entity.py +++ b/homeassistant/components/cloud/entity.py @@ -84,7 +84,7 @@ def _convert_content_to_param( and content.tool_call_id in web_search_calls ): web_search_call = web_search_calls.pop(content.tool_call_id) - web_search_call["status"] = content.tool_result.get( + web_search_call["status"] = content.result.data.get( "status", "completed" ) messages.append(cast("ResponseInputItemParam", web_search_call)) @@ -93,7 +93,12 @@ def _convert_content_to_param( { "type": "function_call_output", "call_id": content.tool_call_id, - "output": json_dumps(content.tool_result), + "output": json_dumps( + { + "data": content.result.data, + "error": content.result.error, + } + ), } ) continue @@ -316,7 +321,10 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have "role": "tool_result", "tool_call_id": event.item.id, "tool_name": "web_search_call", - "tool_result": {"status": event.item.status}, + "result": llm.ToolResult( + data={"status": event.item.status}, + error=event.item.status == "failed", + ), } last_role = "tool_result" elif isinstance(event.item, LLMResponseImageOutputItem): diff --git a/tests/components/cloud/test_entity.py b/tests/components/cloud/test_entity.py index 7c522f8483d114..c9b9362ade86fd 100644 --- a/tests/components/cloud/test_entity.py +++ b/tests/components/cloud/test_entity.py @@ -327,3 +327,40 @@ async def _fake_delta_stream( _, kwargs = cloud.llm.async_generate_data.call_args assert kwargs["response_format"]["json_schema"]["strict"] is False + + +@pytest.mark.parametrize( + ("result", "expected_output"), + [ + pytest.param( + llm.ToolResult(data={"temperature": 21}), + '{"data":{"temperature":21},"error":false}', + id="success", + ), + pytest.param( + llm.ToolResult(data={"error": "Not found"}, error=True), + '{"data":{"error":"Not found"},"error":true}', + id="error", + ), + ], +) +def test_convert_tool_result_to_param( + result: llm.ToolResult, expected_output: str +) -> None: + """Test the tool result is sent with its error flag.""" + content = [ + conversation.ToolResultContent( + agent_id="agent", + tool_call_id="mock-tool-call-id", + tool_name="HassGetState", + result=result, + ) + ] + + assert _convert_content_to_param(content) == [ + { + "type": "function_call_output", + "call_id": "mock-tool-call-id", + "output": expected_output, + } + ] From 6f9375c9054ff50162ebc1ff8ac63d6538ba5aec Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 18 Sep 2026 11:24:24 -0400 Subject: [PATCH 23/26] Use ToolResult in todo (#182540) Co-authored-by: Claude --- homeassistant/components/todo/llm.py | 10 +++++----- tests/components/todo/test_llm.py | 9 +++++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/todo/llm.py b/homeassistant/components/todo/llm.py index d9f28e37dbc505..41335100f8a30f 100644 --- a/homeassistant/components/todo/llm.py +++ b/homeassistant/components/todo/llm.py @@ -15,8 +15,8 @@ LLMContext, Tool, ToolInput, + ToolResult, ) -from homeassistant.util.json import JsonObjectType from .const import DOMAIN, TodoServices from .intent import ( @@ -61,7 +61,7 @@ def __init__(self, todo_lists: list[str]) -> None: @override async def async_call( self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext - ) -> JsonObjectType: + ) -> ToolResult: """Query a to-do list.""" data = self.parameters(tool_input.tool_args) result = intent.async_match_targets( @@ -73,7 +73,7 @@ async def async_call( ), ) if not result.is_match: - return {"success": False, "error": "To-do list not found"} + return ToolResult(data={"error": "To-do list not found"}, error=True) entity_id = result.states[0].entity_id service_data: dict[str, Any] = {"entity_id": entity_id} status = data["status"] @@ -89,9 +89,9 @@ async def async_call( return_response=True, ) if not service_result: - return {"success": False, "error": "To-do list not found"} + return ToolResult(data={"error": "To-do list not found"}, error=True) items = cast(dict, service_result)[entity_id]["items"] - return {"success": True, "result": items} + return ToolResult(data={"items": items}) @callback diff --git a/tests/components/todo/test_llm.py b/tests/components/todo/test_llm.py index bb56b76d47b348..547ca17fad9e68 100644 --- a/tests/components/todo/test_llm.py +++ b/tests/components/todo/test_llm.py @@ -80,10 +80,11 @@ async def test_todo_get_items_tool(hass: HomeAssistant) -> None: assert len(calls) == 1 assert calls[0].data == {"entity_id": [ENTITY_ID], "status": ["needs_action"]} - assert result == { - "success": True, - "result": [{"uid": "1234", "status": "needs_action", "summary": "Buy milk"}], - } + assert result == llm.ToolResult( + data={ + "items": [{"uid": "1234", "status": "needs_action", "summary": "Buy milk"}] + } + ) @pytest.mark.parametrize( From f1f854262a548b641cacf8e5395c4096fea8379a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 18 Sep 2026 11:24:40 -0400 Subject: [PATCH 24/26] Use ToolResult in google_generative_ai_conversation (#182545) Co-authored-by: Claude --- .../entity.py | 7 ++++++- .../snapshots/test_conversation.ambr | 19 +++++++++---------- .../test_conversation.py | 3 ++- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index cfbe08a8fe7767..08f60d0cb8fd65 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -271,7 +271,12 @@ def _create_google_tool_response_parts( return [ Part.from_function_response( name=tool_result.tool_name, - response=_validate_tool_results(tool_result.tool_result), + response=_validate_tool_results( + { + "data": tool_result.result.data, + "error": tool_result.result.error, + } + ), ) for tool_result in parts ] diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index 9996cbab1d23c5..36544e6b152e20 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -27,16 +27,12 @@ name='HassGetCurrentTime', response={ 'data': { - 'failed': [], - 'success': [] + 'data': {<... 2 items at Max depth ...>}, + 'response_type': 'action_done', + 'speech': {<... 1 item at Max depth ...>}, + 'speech_slots': {<... 1 item at Max depth ...>} }, - 'response_type': 'action_done', - 'speech': { - 'plain': {<... 2 items at Max depth ...>} - }, - 'speech_slots': { - 'time': '16:24:17.813343' - } + 'error': False } ) ), @@ -92,7 +88,10 @@ function_response=FunctionResponse( name='test_tool', response={ - 'result': 'Test response' + 'data': { + 'result': 'Test response' + }, + 'error': False } ) ), diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index a1ba7bde51d715..cea19de23b2ecf 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -269,7 +269,8 @@ async def test_function_call( "name": "test_tool", "parts": None, "response": { - "result": "Test response", + "data": {"result": "Test response"}, + "error": False, }, "scheduling": None, "will_continue": None, From 6081351f8a8c77b4ebab244aed28a5eb4b715f7c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 18 Sep 2026 11:25:15 -0400 Subject: [PATCH 25/26] Use ToolResult in mcp (#182541) Co-authored-by: Claude --- homeassistant/components/mcp/coordinator.py | 8 ++-- tests/components/mcp/test_init.py | 44 +++++++++++++++++---- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/mcp/coordinator.py b/homeassistant/components/mcp/coordinator.py index f3bea5f3d58e45..fb099a800530dd 100644 --- a/homeassistant/components/mcp/coordinator.py +++ b/homeassistant/components/mcp/coordinator.py @@ -29,7 +29,6 @@ from homeassistant.helpers import llm from homeassistant.helpers.httpx_client import create_async_httpx_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.json import JsonObjectType from homeassistant.util.ssl import SSL_ALPN_HTTP11, SSLCipherList, client_context from .auth import AuthenticateHeader @@ -148,7 +147,7 @@ async def async_call( hass: HomeAssistant, tool_input: llm.ToolInput, llm_context: llm.LLMContext, - ) -> JsonObjectType: + ) -> llm.ToolResult: """Call the tool.""" try: async with asyncio.timeout(TIMEOUT): @@ -188,7 +187,10 @@ async def async_call( raise HomeAssistantError( f"Error communicating with MCP server when calling tool: {error}" ) from error - return result.model_dump(exclude_unset=True, exclude_none=True) + return llm.ToolResult( + data=result.model_dump(exclude_unset=True, exclude_none=True), + error=bool(result.isError), + ) class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): diff --git a/tests/components/mcp/test_init.py b/tests/components/mcp/test_init.py index a25005da434504..7e903e5066d29f 100644 --- a/tests/components/mcp/test_init.py +++ b/tests/components/mcp/test_init.py @@ -317,8 +317,42 @@ async def test_llm_get_api_tools( } +@pytest.mark.parametrize( + ("call_tool_result", "expected_result"), + [ + pytest.param( + CallToolResult( + content=[TextContent(type="text", text="User was born in February")] + ), + llm.ToolResult( + data={ + "content": [{"text": "User was born in February", "type": "text"}] + } + ), + id="success", + ), + pytest.param( + CallToolResult( + content=[TextContent(type="text", text="Memory search failed")], + isError=True, + ), + llm.ToolResult( + data={ + "content": [{"text": "Memory search failed", "type": "text"}], + "isError": True, + }, + error=True, + ), + id="error", + ), + ], +) async def test_call_tool( - hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, + call_tool_result: CallToolResult, + expected_result: llm.ToolResult, ) -> None: """Test calling an MCP Tool through the LLM API.""" mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( @@ -337,9 +371,7 @@ async def test_call_tool( tool = api_instance.tools[0] assert tool.name == "search_memory" - mock_mcp_client.return_value.call_tool.return_value = CallToolResult( - content=[TextContent(type="text", text="User was born in February")] - ) + mock_mcp_client.return_value.call_tool.return_value = call_tool_result result = await tool.async_call( hass, llm.ToolInput( @@ -347,9 +379,7 @@ async def test_call_tool( ), create_llm_context(), ) - assert result == { - "content": [{"text": "User was born in February", "type": "text"}] - } + assert result == expected_result async def test_call_tool_fails( From 434a246b6491d4bb84abe1bcb9663367ae75e616 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 18 Sep 2026 11:25:28 -0400 Subject: [PATCH 26/26] Use ToolResult in open_router (#182549) Co-authored-by: Claude --- homeassistant/components/open_router/entity.py | 4 +++- tests/components/open_router/snapshots/test_conversation.ambr | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/open_router/entity.py b/homeassistant/components/open_router/entity.py index f97d0b142720f6..8613c5c9488b61 100644 --- a/homeassistant/components/open_router/entity.py +++ b/homeassistant/components/open_router/entity.py @@ -109,7 +109,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 diff --git a/tests/components/open_router/snapshots/test_conversation.ambr b/tests/components/open_router/snapshots/test_conversation.ambr index d6e0c5508ddd99..8abcb03249b23f 100644 --- a/tests/components/open_router/snapshots/test_conversation.ambr +++ b/tests/components/open_router/snapshots/test_conversation.ambr @@ -262,7 +262,7 @@ ]), }), dict({ - 'content': '{"speech":{"plain":{"speech":"12:00 PM","extra_data":null}},"response_type":"action_done","speech_slots":{"time":"12:00:00"},"data":{"success":[],"failed":[]}}', + 'content': '{"data":{"speech":{"plain":{"speech":"12:00 PM","extra_data":null}},"response_type":"action_done","speech_slots":{"time":"12:00:00"},"data":{"success":[],"failed":[]}},"error":false}', 'role': 'tool', 'tool_call_id': 'mock_tool_call_id', }), @@ -289,7 +289,7 @@ ]), }), dict({ - 'content': '"value1"', + 'content': '{"data":"value1","error":false}', 'role': 'tool', 'tool_call_id': 'call_call_1', }),