From a33fc1bfe7baf0ace3d87d695c5f9117b0eb79b5 Mon Sep 17 00:00:00 2001 From: Manuel Stahl Date: Sat, 19 Sep 2026 05:36:44 +0200 Subject: [PATCH 01/43] Update pystiebeleltron to 0.8.0 (#182618) --- homeassistant/components/stiebel_eltron/manifest.json | 2 +- requirements_all.txt | 2 +- script/hassfest/requirements.py | 4 ---- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/stiebel_eltron/manifest.json b/homeassistant/components/stiebel_eltron/manifest.json index 29c9fd19563b14..0498f1fb4b5613 100644 --- a/homeassistant/components/stiebel_eltron/manifest.json +++ b/homeassistant/components/stiebel_eltron/manifest.json @@ -14,5 +14,5 @@ "iot_class": "local_polling", "loggers": ["pystiebeleltron"], "quality_scale": "silver", - "requirements": ["pystiebeleltron==0.7.0"] + "requirements": ["pystiebeleltron==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6be7c749bc93d9..aa4cb5e424ddd6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2681,7 +2681,7 @@ pyspeex-noise==1.0.2 pysqueezebox==0.14.0 # homeassistant.components.stiebel_eltron -pystiebeleltron==0.7.0 +pystiebeleltron==0.8.0 # homeassistant.components.suez_water pysuezV2==2.0.7 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 057175bd16f7ed..8c8bd084f5ec63 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -79,10 +79,6 @@ # Current has an upper bound on major >=3.11.0,<4.0.0 "pysmarty2": {"pymodbus"} }, - "stiebel_eltron": { - # Current has an upper bound on major >=3.10.0,<4.0.0 - "pystiebeleltron": {"pymodbus"} - }, "telegram_bot": {"python-telegram-bot": {"httpx"}}, "xiaomi_miio": { "python-miio": {"zeroconf"}, From a11edea57438d47b856dba76d0174211683bc14a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 18 Sep 2026 23:37:18 -0400 Subject: [PATCH 02/43] Deprecate ToolResultContent.tool_result (#182551) Co-authored-by: Claude --- .../components/conversation/chat_log.py | 27 +++++- homeassistant/helpers/llm.py | 22 +++++ .../components/anthropic/test_conversation.py | 4 +- .../assist_pipeline/test_pipeline.py | 2 +- .../components/conversation/test_chat_log.py | 20 +++-- .../conversation/test_default_agent.py | 4 +- tests/components/ollama/test_conversation.py | 2 +- tests/helpers/test_llm.py | 89 +++++++++++++------ 8 files changed, 128 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 8927ce6310b3e8..7cf36eed470e8e 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -20,7 +20,7 @@ from homeassistant.util.json import JsonObjectType from . import trace -from .const import ChatLogEventType +from .const import DOMAIN, ChatLogEventType from .models import ConversationInput, ConversationResult DATA_CHAT_LOGS: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_logs") @@ -298,7 +298,20 @@ class ToolResultContent: @property def tool_result(self) -> JsonObjectType: - """Return the data of the result.""" + """Return the data of the result. + + Deprecated compatibility shim: the result is available as `result`. + It can be removed in HA Core 2027.11. + """ + frame.report_usage( + "accesses `ToolResultContent.tool_result`, which is deprecated; " + "use `ToolResultContent.result` instead", + breaks_in_ha_version="2027.11.0", + core_behavior=frame.ReportBehavior.ERROR, + core_integration_behavior=frame.ReportBehavior.ERROR, + custom_integration_behavior=frame.ReportBehavior.LOG, + exclude_integrations={DOMAIN}, + ) return self.result.data def as_dict(self) -> dict[str, Any]: @@ -309,6 +322,7 @@ def as_dict(self) -> dict[str, Any]: "tool_call_id": self.tool_call_id, "tool_name": self.tool_name, "result": asdict(self.result), + # Deprecated, can be removed in HA Core 2027.11. "tool_result": self.result.data, "created": self.created, } @@ -594,6 +608,15 @@ async def async_add_delta_content_stream( self.delta_listener(self, filtered_delta) elif delta["role"] == "tool_result": if (result := delta.get("result")) is None: + frame.report_usage( + "sets `tool_result` on a tool result delta, which is " + "deprecated; set `result` to a ToolResult instead", + breaks_in_ha_version="2027.11.0", + core_behavior=frame.ReportBehavior.ERROR, + core_integration_behavior=frame.ReportBehavior.ERROR, + custom_integration_behavior=frame.ReportBehavior.LOG, + exclude_integrations={DOMAIN}, + ) result = llm.ToolResult(data=delta["tool_result"]) content = ToolResultContent( agent_id=agent_id, diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index ed96216781ce39..77f9405ac74e09 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -25,6 +25,7 @@ config_validation as cv, device_registry as dr, floor_registry as fr, + frame, intent, selector, service, @@ -213,9 +214,30 @@ async def async_call_tool(self, tool_input: ToolInput) -> ToolResult: result = await tool.async_call(self.api.hass, tool_input, self.llm_context) if isinstance(result, ToolResult): return result + frame.report_usage( + "returns a JSON object from a tool, which is deprecated; return a " + "ToolResult instead", + breaks_in_ha_version="2027.11.0", + core_behavior=frame.ReportBehavior.ERROR, + core_integration_behavior=frame.ReportBehavior.ERROR, + custom_integration_behavior=frame.ReportBehavior.LOG, + # The tool call has returned, so its frame is gone from the stack. + integration_domain=_tool_integration_domain(tool), + ) return ToolResult(data=result) +def _tool_integration_domain(tool: Tool) -> str | None: + """Return the domain of the integration that provides the tool.""" + while isinstance(tool, NamespacedTool): + tool = tool.tool + module = type(tool).__module__ + for prefix in ("custom_components.", "homeassistant.components."): + if module.startswith(prefix): + return module.removeprefix(prefix).partition(".")[0] + return None + + @dataclass(slots=True, kw_only=True) class API(ABC): """An API to expose to LLMs.""" diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index b1b409ab052a35..78dd9828b4b91f 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -433,7 +433,7 @@ async def test_function_call( mock_tool.parameters = probatio.Schema( {probatio.Optional("param1", description="Test parameters"): str} ) - mock_tool.async_call.return_value = "Test response" + mock_tool.async_call.return_value = llm.ToolResult(data="Test response") mock_get_tools.return_value = LLMTools(tools=[mock_tool]) @@ -958,7 +958,7 @@ async def test_extended_thinking_tool_call( mock_tool.parameters = probatio.Schema( {probatio.Optional("param1", description="Test parameters"): str} ) - mock_tool.async_call.return_value = "Test response" + mock_tool.async_call.return_value = llm.ToolResult(data="Test response") mock_get_tools.return_value = LLMTools(tools=[mock_tool]) diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 99044dd9437f0a..c24cab61953ad3 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -1869,7 +1869,7 @@ async def stream_llm_response(): mock_tool.name = "test_tool" mock_tool.description = "Test function" mock_tool.parameters = probatio.Schema({}) - mock_tool.async_call.return_value = "Test response" + mock_tool.async_call.return_value = llm.ToolResult(data="Test response") with ( patch( diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index 249388456b71bd..72b505daf6dc7e 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -439,7 +439,7 @@ async def test_tool_call( mock_tool.parameters = probatio.Schema( {probatio.Optional("param1", description="Test parameters"): str} ) - mock_tool.async_call.return_value = "Test response" + mock_tool.async_call.return_value = llm.ToolResult(data="Test response") with ( patch( @@ -704,9 +704,9 @@ async def test_add_delta_content_stream( async def tool_call( hass: HomeAssistant, tool_input: llm.ToolInput, llm_context: llm.LLMContext - ) -> str: + ) -> llm.ToolResult: """Call the tool.""" - return tool_input.tool_args["param1"] + return llm.ToolResult(data=tool_input.tool_args["param1"]) mock_tool.async_call.side_effect = tool_call expected_delta = [] @@ -1042,8 +1042,9 @@ def event_callback( assert len(received_events) == events_before_unsubscribe +@pytest.mark.usefixtures("mock_integration_frame") async def test_tool_result_content_deprecated_property() -> None: - """Test the deprecated tool_result property returns the result data.""" + """Test reading the deprecated tool_result property is reported.""" content = ToolResultContent( agent_id="mock-agent-id", tool_call_id="mock-tool-call-id", @@ -1051,14 +1052,16 @@ async def test_tool_result_content_deprecated_property() -> None: result=llm.ToolResult(data={"answer": 42}), ) - assert content.tool_result == {"answer": 42} + with pytest.raises(RuntimeError, match="ToolResultContent.tool_result"): + _ = content.tool_result +@pytest.mark.usefixtures("mock_integration_frame") async def test_add_delta_content_stream_deprecated_tool_result( hass: HomeAssistant, mock_conversation_input: ConversationInput, ) -> None: - """Test a delta carrying the deprecated tool_result key is still accepted.""" + """Test setting the deprecated tool_result key on a delta is reported.""" async def stream(): """Yield a tool result delta using the deprecated key.""" @@ -1072,12 +1075,11 @@ async def stream(): with ( chat_session.async_get_chat_session(hass) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + pytest.raises(RuntimeError, match="tool result delta"), ): - results = [ + _ = [ content async for content in chat_log.async_add_delta_content_stream( "mock-agent-id", stream() ) ] - - assert results[0].result == llm.ToolResult(data={"answer": 42}) diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 54732b1649595c..5ff00c24323c84 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -3520,7 +3520,7 @@ async def test_intent_tool_call_in_chat_log(hass: HomeAssistant) -> None: # Verify tool result was stored assert tool_result_content is not None assert tool_result_content.tool_name == "HassTurnOn" - assert tool_result_content.tool_result["response_type"] == "action_done" + assert tool_result_content.result.data["response_type"] == "action_done" # Verify final assistant content with speech assert assistant_content is not None @@ -3569,7 +3569,7 @@ async def test_trigger_tool_call_in_chat_log(hass: HomeAssistant) -> None: # Verify tool result was stored assert tool_result_content is not None assert tool_result_content.tool_name == "trigger_sentence" - assert tool_result_content.tool_result["response"] == trigger_response + assert tool_result_content.result.data["response"] == trigger_response @pytest.mark.usefixtures("init_components") diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 6a365d9a17c15c..90989e51aed43b 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -314,7 +314,7 @@ async def test_function_call( {probatio.Optional("param1", description="Test parameters"): str}, extra=probatio.ALLOW_EXTRA, ) - mock_tool.async_call.return_value = "Test response" + mock_tool.async_call.return_value = llm.ToolResult(data="Test response") mock_get_tools.return_value = LLMTools(tools=[mock_tool]) diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 1872a294ddb2e0..fb75012b45f3e3 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -25,7 +25,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util.json import JsonObjectType -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, MockModule, mock_integration @pytest.fixture(autouse=True) @@ -150,7 +150,9 @@ async def test_call_non_intent_tool_preserves_blank_arguments( tool_args = {"name": "", "response": " ", "other": None} tool = MagicMock(spec=llm.Tool) tool.name = "test_tool" - tool.async_call = AsyncMock(return_value={"tool_args": tool_args}) + tool.async_call = AsyncMock( + return_value=llm.ToolResult(data={"tool_args": tool_args}) + ) instance = llm.APIInstance( MyAPI(hass=hass, id="test", name="Test"), "", llm_context, [tool] ) @@ -161,31 +163,14 @@ async def test_call_non_intent_tool_preserves_blank_arguments( assert tool.async_call.await_args.args[1].tool_args is tool_args -@pytest.mark.parametrize( - ("tool_return_value", "expected"), - [ - pytest.param( - {"answer": 42}, - llm.ToolResult(data={"answer": 42}), - id="plain-json-object", - ), - pytest.param( - llm.ToolResult(data={"answer": 42}, error=True), - llm.ToolResult(data={"answer": 42}, error=True), - id="tool-result", - ), - ], -) async def test_call_tool_result( - hass: HomeAssistant, - llm_context: llm.LLMContext, - tool_return_value: llm.ToolResult | JsonObjectType, - expected: llm.ToolResult, + hass: HomeAssistant, llm_context: llm.LLMContext ) -> None: - """Test a tool result is returned as is and a JSON object is wrapped.""" + """Test a tool result is returned as is.""" + expected = llm.ToolResult(data={"answer": 42}, error=True) tool = MagicMock(spec=llm.Tool) tool.name = "test_tool" - tool.async_call = AsyncMock(return_value=tool_return_value) + tool.async_call = AsyncMock(return_value=expected) instance = llm.APIInstance( MyAPI(hass=hass, id="test", name="Test"), "", llm_context, [tool] ) @@ -193,6 +178,58 @@ async def test_call_tool_result( assert await instance.async_call_tool(llm.ToolInput(tool.name, {})) == expected +@pytest.mark.usefixtures("mock_integration_frame") +async def test_call_tool_deprecated_json_object( + hass: HomeAssistant, llm_context: llm.LLMContext +) -> None: + """Test returning a JSON object from a tool is reported.""" + tool = MagicMock(spec=llm.Tool) + tool.name = "test_tool" + tool.async_call = AsyncMock(return_value={"answer": 42}) + instance = llm.APIInstance( + MyAPI(hass=hass, id="test", name="Test"), "", llm_context, [tool] + ) + + with pytest.raises(RuntimeError, match="returns a JSON object from a tool"): + await instance.async_call_tool(llm.ToolInput(tool.name, {})) + + +async def test_call_tool_deprecated_json_object_custom_integration( + hass: HomeAssistant, + llm_context: llm.LLMContext, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a custom integration tool returning a JSON object is logged, not raised.""" + mock_integration(hass, MockModule("my_custom"), built_in=False) + + class CustomTool(llm.Tool): + """Tool provided by a custom integration.""" + + name = "test_tool" + + async def async_call( + self, + hass: HomeAssistant, + tool_input: llm.ToolInput, + llm_context: llm.LLMContext, + ) -> JsonObjectType: + """Return a plain JSON object.""" + return {"answer": 42} + + # The tool call has returned by the time it is reported, so the domain is + # taken from the tool rather than the stack. + CustomTool.__module__ = "custom_components.my_custom.llm" + tool = CustomTool() + instance = llm.APIInstance( + MyAPI(hass=hass, id="test", name="Test"), "", llm_context, [tool] + ) + + assert await instance.async_call_tool( + llm.ToolInput(tool.name, {}) + ) == llm.ToolResult(data={"answer": 42}) + assert "returns a JSON object from a tool" in caplog.text + + @pytest.mark.parametrize("namespaced", [False, True]) async def test_intent_tool_omits_blank_arguments( hass: HomeAssistant, llm_context: llm.LLMContext, namespaced: bool @@ -1381,8 +1418,10 @@ def __init__(self, name: str, description: str) -> None: async def async_call( self, hass: HomeAssistant, tool_input: llm.ToolInput, _: llm.LLMContext - ) -> JsonObjectType: - return {"result": {tool_input.tool_name: tool_input.tool_args}} + ) -> llm.ToolResult: + return llm.ToolResult( + data={"result": {tool_input.tool_name: tool_input.tool_args}} + ) api1 = MyAPI(hass=hass, id="api-1", name="API 1") api1.prompt = "This is prompt 1" From d59ebdb857b6ed3ad3989c5805de174286523287 Mon Sep 17 00:00:00 2001 From: Michael Davie Date: Sat, 19 Sep 2026 01:12:20 -0400 Subject: [PATCH 03/43] Bump env-canada to 0.20.3 (#182650) --- homeassistant/components/environment_canada/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index 6dd499bf456215..3e10983a210852 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env-canada==0.20.0"] + "requirements": ["env-canada==0.20.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index aa4cb5e424ddd6..40876e6f01be32 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -974,7 +974,7 @@ enocean-async==0.4.2 enturclient==0.2.4 # homeassistant.components.environment_canada -env-canada==0.20.0 +env-canada==0.20.3 # homeassistant.components.season ephem==4.1.6 From 7014cea781c2293307bdaa019730f4beabe9abd3 Mon Sep 17 00:00:00 2001 From: Manu Date: Sat, 19 Sep 2026 07:13:04 +0200 Subject: [PATCH 04/43] Bump python-xbox to 0.3.0 (#182643) --- homeassistant/components/xbox/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xbox/manifest.json b/homeassistant/components/xbox/manifest.json index 77970becfbd4a3..7305fb6a905cee 100644 --- a/homeassistant/components/xbox/manifest.json +++ b/homeassistant/components/xbox/manifest.json @@ -14,7 +14,7 @@ "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["python-xbox==0.2.2"], + "requirements": ["python-xbox==0.3.0"], "ssdp": [ { "manufacturer": "Microsoft Corporation", diff --git a/requirements_all.txt b/requirements_all.txt index 40876e6f01be32..ff1cb74205b962 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2847,7 +2847,7 @@ python-technove==2.1.3 python-telegram-bot[socks]==22.7 # homeassistant.components.xbox -python-xbox==0.2.2 +python-xbox==0.3.0 # homeassistant.components.egardia pythonegardia==1.0.52 From 4fdcc2a3f5bf76716b6f03f3cef322a54d81478b Mon Sep 17 00:00:00 2001 From: Manu Date: Sat, 19 Sep 2026 07:13:30 +0200 Subject: [PATCH 05/43] Add codeowner to SMTP integration (#182638) --- CODEOWNERS | 2 ++ homeassistant/components/smtp/manifest.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 099f990624cf39..1496f15a4c5496 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1750,6 +1750,8 @@ CLAUDE.md @home-assistant/core /tests/components/smhi/ @gjohansson-ST /homeassistant/components/smlight/ @tl-sl /tests/components/smlight/ @tl-sl +/homeassistant/components/smtp/ @tr4nt0r +/tests/components/smtp/ @tr4nt0r /homeassistant/components/snapcast/ @luar123 /tests/components/snapcast/ @luar123 /homeassistant/components/snmp/ @nmaggioni diff --git a/homeassistant/components/smtp/manifest.json b/homeassistant/components/smtp/manifest.json index d2860443d56aa9..dbd615deec2b3f 100644 --- a/homeassistant/components/smtp/manifest.json +++ b/homeassistant/components/smtp/manifest.json @@ -1,7 +1,7 @@ { "domain": "smtp", "name": "SMTP", - "codeowners": [], + "codeowners": ["@tr4nt0r"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/smtp", "integration_type": "service", From 69a5efb37b8bb1274dac0fdd9eca18da69a882c8 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:14:28 +0200 Subject: [PATCH 06/43] Mark disconnected UniFi Protect sirens and relays unavailable (#182635) --- .../unifiprotect/alarm_control_panel.py | 5 +- .../components/unifiprotect/entity.py | 5 +- .../components/unifiprotect/siren.py | 8 ++- .../components/unifiprotect/switch.py | 7 ++- tests/components/unifiprotect/test_relay.py | 44 +++++++++++++++ tests/components/unifiprotect/test_siren.py | 54 ++++++++++++++++++- 6 files changed, 115 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/unifiprotect/alarm_control_panel.py b/homeassistant/components/unifiprotect/alarm_control_panel.py index b9316cc2669f00..472aa43a4c7500 100644 --- a/homeassistant/components/unifiprotect/alarm_control_panel.py +++ b/homeassistant/components/unifiprotect/alarm_control_panel.py @@ -107,8 +107,9 @@ def _refresh_alarm_state(self) -> None: self._attr_alarm_state = None return # arm_mode is delivered over the public devices websocket, so - # availability tracks the public WS health (like relay/siren), not the - # private connection the base class would otherwise apply for the NVR. + # availability tracks the public WS health, not the private connection + # the base class would otherwise apply for the NVR. The NVR carries no + # device state, so there is nothing else to gate on. self._attr_available = self.data.last_public_update_success # Fall back to DISARMED for unknown future status values rather than # rendering the entity as ``unknown``. diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 8dd233c2184eea..c00388d0f2c3c6 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -570,7 +570,10 @@ class ProtectFobEntity(Entity): ``ProtectApiClient.public_bootstrap.fobs`` and is refreshed over the public devices websocket, so it does not use the private-device machinery in :class:`BaseProtectEntity`. Availability follows the public websocket health - and the fob's presence in the bootstrap, mirroring the relay switch. + and the fob's presence in the bootstrap. Unlike every other public device it + deliberately ignores ``state``: Protect models a fob's reachability as + ``away_state``, which the status sensor surfaces, so gating on ``state`` + would take that sensor away exactly when it has something to report. Subclasses fed by the events websocket set ``_ufp_requires_events_ws`` so they also go unavailable when that stream drops. """ diff --git a/homeassistant/components/unifiprotect/siren.py b/homeassistant/components/unifiprotect/siren.py index 5b6cabfa928b8d..7f22fe2be41063 100644 --- a/homeassistant/components/unifiprotect/siren.py +++ b/homeassistant/components/unifiprotect/siren.py @@ -4,7 +4,7 @@ import logging from typing import Any, override -from uiprotect.data import PublicDeviceModel, Siren, SirenDuration +from uiprotect.data import DeviceState, PublicDeviceModel, Siren, SirenDuration from homeassistant.components.siren import ( ATTR_DURATION, @@ -102,7 +102,11 @@ def _siren(self) -> Siren | None: @callback def _update_from_siren(self, siren: Siren) -> None: """Refresh cached attributes from the siren object.""" - self._attr_available = self.data.last_public_update_success + # A siren that dropped off the console stays in the bootstrap. + self._attr_available = ( + self.data.last_public_update_success + and siren.state is DeviceState.CONNECTED + ) self._attr_is_on = siren.is_active @callback diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index f35470a5d873b5..0fc189d473c978 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -7,6 +7,7 @@ from uiprotect.data import ( Camera, + DeviceState, ModelType, ProtectAdoptableDeviceModel, PublicDeviceModel, @@ -660,7 +661,11 @@ def _update_from_relay(self, relay: Relay) -> None: self._attr_available = False self._attr_is_on = None return - self._attr_available = self.data.last_public_update_success + # A relay that dropped off the console stays in the bootstrap. + self._attr_available = ( + self.data.last_public_update_success + and relay.state is DeviceState.CONNECTED + ) self._attr_is_on = ( _RELAY_STATE_MAP.get(output.state) if output.state is not None else None ) diff --git a/tests/components/unifiprotect/test_relay.py b/tests/components/unifiprotect/test_relay.py index b4107c90e6072c..4d6c42c1a9bfeb 100644 --- a/tests/components/unifiprotect/test_relay.py +++ b/tests/components/unifiprotect/test_relay.py @@ -6,6 +6,7 @@ import pytest from uiprotect.data import ( + DeviceState, ModelType, PublicRelayOutput, Relay, @@ -62,6 +63,7 @@ def _make_output( def _make_relay( *, outputs: list[Mock] | None = None, + state: DeviceState = DeviceState.CONNECTED, ) -> Mock: """Build a mock :class:`Relay` whose ``activate_output`` is awaitable.""" relay = Mock(spec=Relay) @@ -69,6 +71,7 @@ def _make_relay( relay.mac = RELAY_MAC relay.name = RELAY_NAME relay.model = ModelType.RELAY + relay.state = state relay.outputs = outputs if outputs is not None else [_make_output()] def get_output(output_id: int) -> Mock | None: @@ -389,6 +392,47 @@ async def test_relay_switch_becomes_unavailable_when_relay_removed( assert state.state == STATE_UNAVAILABLE +@pytest.mark.parametrize( + "state", + [DeviceState.DISCONNECTED, DeviceState.CONNECTING, DeviceState.UNKNOWN], +) +async def test_relay_switch_unavailable_when_not_connected_at_setup( + hass: HomeAssistant, + ufp: MockUFPFixture, + state: DeviceState, +) -> None: + """A relay that is not connected at setup starts out unavailable.""" + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = _make_public_bootstrap(_make_relay(state=state)) + + await init_entry(hass, ufp, []) + + assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_UNAVAILABLE + + +async def test_relay_switch_unavailable_when_disconnected( + hass: HomeAssistant, + ufp_with_relay: tuple[MockUFPFixture, Mock], +) -> None: + """A relay that drops off the console is unavailable, and recovers.""" + ufp, relay = ufp_with_relay + relay.outputs[0].state = RelayOutputState.ON + await init_entry(hass, ufp, []) + assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_ON + + relay.state = DeviceState.DISCONNECTED + ufp.devices_ws_subscription(public_device_ws_message(relay)) + await hass.async_block_till_done() + + assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_UNAVAILABLE + + relay.state = DeviceState.CONNECTED + ufp.devices_ws_subscription(public_device_ws_message(relay)) + await hass.async_block_till_done() + + assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_ON + + async def test_relay_switch_availability_follows_websocket_state( hass: HomeAssistant, ufp_with_relay: tuple[MockUFPFixture, Mock], diff --git a/tests/components/unifiprotect/test_siren.py b/tests/components/unifiprotect/test_siren.py index dc16ef132eef0c..55c8b9e5af0ca7 100644 --- a/tests/components/unifiprotect/test_siren.py +++ b/tests/components/unifiprotect/test_siren.py @@ -4,7 +4,14 @@ from unittest.mock import AsyncMock, Mock import pytest -from uiprotect.data import ModelType, PublicSirenStatus, Siren, SirenDuration, WSAction +from uiprotect.data import ( + DeviceState, + ModelType, + PublicSirenStatus, + Siren, + SirenDuration, + WSAction, +) from uiprotect.exceptions import ClientError, NotAuthorized from uiprotect.websocket import WebsocketState @@ -44,7 +51,9 @@ SIREN_ENTITY_ID = "siren.garage_siren" -def _make_siren(*, is_active: bool = False) -> Mock: +def _make_siren( + *, is_active: bool = False, state: DeviceState = DeviceState.CONNECTED +) -> Mock: """Build a mock :class:`Siren`.""" status = Mock(spec=PublicSirenStatus) status.is_active = is_active @@ -56,6 +65,7 @@ def _make_siren(*, is_active: bool = False) -> Mock: siren.mac = SIREN_MAC siren.name = SIREN_NAME siren.model = ModelType.SIREN + siren.state = state siren.volume = 50 siren.siren_status = status siren.is_active = is_active @@ -599,6 +609,46 @@ async def test_siren_auto_off_when_already_expired_at_update( assert state.state == STATE_OFF +@pytest.mark.parametrize( + "state", + [DeviceState.DISCONNECTED, DeviceState.CONNECTING, DeviceState.UNKNOWN], +) +async def test_siren_unavailable_when_not_connected_at_setup( + hass: HomeAssistant, + ufp: MockUFPFixture, + state: DeviceState, +) -> None: + """A siren that is not connected at setup starts out unavailable.""" + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = _make_public_bootstrap(_make_siren(state=state)) + + await init_entry(hass, ufp, []) + + assert hass.states.get(SIREN_ENTITY_ID).state == STATE_UNAVAILABLE + + +async def test_siren_unavailable_when_disconnected( + hass: HomeAssistant, + ufp_with_siren: MockUFPFixture, + siren: Mock, +) -> None: + """A siren that drops off the console is unavailable, and recovers.""" + await init_entry(hass, ufp_with_siren, []) + assert hass.states.get(SIREN_ENTITY_ID).state == STATE_OFF + + siren.state = DeviceState.DISCONNECTED + ufp_with_siren.devices_ws_subscription(_make_ws_msg(siren)) + await hass.async_block_till_done() + + assert hass.states.get(SIREN_ENTITY_ID).state == STATE_UNAVAILABLE + + siren.state = DeviceState.CONNECTED + ufp_with_siren.devices_ws_subscription(_make_ws_msg(siren)) + await hass.async_block_till_done() + + assert hass.states.get(SIREN_ENTITY_ID).state == STATE_OFF + + async def test_siren_unavailable_on_delete_event( hass: HomeAssistant, ufp_with_siren: MockUFPFixture, From 744fd403d23c0c8ac53f7b4c5399e70b0bdca9a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Mind=C3=AAllo=20de=20Andrade?= Date: Sat, 19 Sep 2026 04:10:18 -0300 Subject: [PATCH 07/43] Bump midea-local to 12.1.0 (#182624) --- homeassistant/components/midea/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/midea/manifest.json b/homeassistant/components/midea/manifest.json index 5ee46fc762e1bf..4170de82e7ddcf 100644 --- a/homeassistant/components/midea/manifest.json +++ b/homeassistant/components/midea/manifest.json @@ -13,5 +13,5 @@ "iot_class": "local_polling", "loggers": ["midealocal"], "quality_scale": "bronze", - "requirements": ["midea-local==12.0.0"] + "requirements": ["midea-local==12.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ff1cb74205b962..0a96650e12d5a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1635,7 +1635,7 @@ micloud==0.5 microBeesPy==0.3.5 # homeassistant.components.midea -midea-local==12.0.0 +midea-local==12.1.0 # homeassistant.components.mill mill-local==0.5.0 From 019eeca0825eb1df260e10b24bc0239d129a46cb Mon Sep 17 00:00:00 2001 From: ziptbm <46136563+ziptbm@users.noreply.github.com> Date: Sat, 19 Sep 2026 03:10:50 -0400 Subject: [PATCH 08/43] Bump open-garage to 0.3.1 (#182626) --- homeassistant/components/opengarage/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/opengarage/manifest.json b/homeassistant/components/opengarage/manifest.json index adc456d8d8bb27..c4d2842f3fe6ea 100644 --- a/homeassistant/components/opengarage/manifest.json +++ b/homeassistant/components/opengarage/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["opengarage"], - "requirements": ["open-garage==0.2.0"] + "requirements": ["open-garage==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0a96650e12d5a3..23d7ad1d3f546f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1825,7 +1825,7 @@ onvif-zeep-async==4.2.1 onvif_parsers==2.3.0 # homeassistant.components.opengarage -open-garage==0.2.0 +open-garage==0.3.1 # homeassistant.components.open_meteo open-meteo==0.3.2 From 9c21997ae105fa8a4e5183e8bca452dbcfb122a7 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:10:54 +0800 Subject: [PATCH 09/43] Upgrade to quality scale: silver (#182510) --- homeassistant/components/collection_image/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/collection_image/manifest.json b/homeassistant/components/collection_image/manifest.json index ee5118b51c804d..530ff5e4eb3f16 100644 --- a/homeassistant/components/collection_image/manifest.json +++ b/homeassistant/components/collection_image/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/collection_image", "integration_type": "service", "iot_class": "calculated", - "quality_scale": "bronze" + "quality_scale": "silver" } From a1ab6a3c7dda66ed62160b0aebe9b46cbae5ffd7 Mon Sep 17 00:00:00 2001 From: Hamish Date: Sat, 19 Sep 2026 16:41:12 +0930 Subject: [PATCH 10/43] Gatus: Mark entity translations as complete (#182479) --- homeassistant/components/gatus/quality_scale.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/gatus/quality_scale.yaml b/homeassistant/components/gatus/quality_scale.yaml index ed597d3a37ee2b..a5dadd68ea642c 100644 --- a/homeassistant/components/gatus/quality_scale.yaml +++ b/homeassistant/components/gatus/quality_scale.yaml @@ -67,9 +67,7 @@ rules: entity-disabled-by-default: status: exempt comment: All entities represent monitored services and should be enabled by default. - entity-translations: - status: exempt - comment: Entity names are dynamically provided by the Gatus service. + entity-translations: done exception-translations: done icon-translations: done reconfiguration-flow: done From fc9713a5b6f59dee8d9eb39c68297debecf3b51e Mon Sep 17 00:00:00 2001 From: Chris <53621277+smitec11@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:11:18 +0200 Subject: [PATCH 11/43] Assert the translation metadata in the kodi notify error test (#182605) Co-authored-by: Claude Opus 5 --- tests/components/kodi/test_notify.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/components/kodi/test_notify.py b/tests/components/kodi/test_notify.py index fedd96be52daed..62110a53734718 100644 --- a/tests/components/kodi/test_notify.py +++ b/tests/components/kodi/test_notify.py @@ -5,6 +5,7 @@ import jsonrpc_async import pytest +from homeassistant.components.kodi.const import DOMAIN from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -47,7 +48,10 @@ async def test_send_message_transport_error(hass: HomeAssistant) -> None: ) await setup_notify(hass, server) - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError) as err: await hass.services.async_call( NOTIFY_DOMAIN, "kodi", {"message": "Hello"}, blocking=True ) + + assert err.value.translation_domain == DOMAIN + assert err.value.translation_key == "notify_failed" From c1bce6ee8080baa743ac7075062ba4c97d5fa111 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:11:34 +0200 Subject: [PATCH 12/43] Add groups configuration for CodeQL action (#182418) --- .github/dependabot.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1a4e0cbfe1f827..9386de6f4d081c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,6 +11,10 @@ updates: - github_actions cooldown: default-days: 7 + groups: + codeql-action: + patterns: + - "github/codeql-action/*" ignore: # Managed by gh aw compile. Version-locked to the gh-aw compiler; do not bump. - dependency-name: "github/gh-aw-actions/**" From 529a409558963f6fd057aee0a0f6e96320ff87b1 Mon Sep 17 00:00:00 2001 From: Daan Vervacke Date: Sat, 19 Sep 2026 09:11:45 +0200 Subject: [PATCH 13/43] Bump diematic-modbus to 0.8.0 (#182597) --- homeassistant/components/de_dietrich/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/de_dietrich/manifest.json b/homeassistant/components/de_dietrich/manifest.json index e980c3c99c7cae..cacf02a8101f6e 100644 --- a/homeassistant/components/de_dietrich/manifest.json +++ b/homeassistant/components/de_dietrich/manifest.json @@ -8,5 +8,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["diematic-modbus==0.7.5"] + "requirements": ["diematic-modbus==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 23d7ad1d3f546f..0566df72a58730 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -872,7 +872,7 @@ devolo-home-control-api==0.19.1 devolo-plc-api==1.5.1 # homeassistant.components.de_dietrich -diematic-modbus==0.7.5 +diematic-modbus==0.8.0 # homeassistant.components.chacon_dio dio-chacon-wifi-api==1.3.0 From c1ac51d9a39e761c1c81e0b2ea01a227cb8b3674 Mon Sep 17 00:00:00 2001 From: Boris Schauer Date: Sat, 19 Sep 2026 09:18:43 +0200 Subject: [PATCH 14/43] Add sensoroverride to ViCare ventilation reason options (#182378) --- homeassistant/components/vicare/sensor.py | 1 + homeassistant/components/vicare/strings.json | 1 + tests/components/vicare/snapshots/test_sensor.ambr | 6 ++++++ 3 files changed, 8 insertions(+) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index a10d37bebfd642..5b23d3e7179f48 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -1023,6 +1023,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM "sensordriven", "silent", "forcedlevelfour", + "sensoroverride", ], ), ViCareSensorEntityDescription( diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index afdef6212a4959..b15bd66a962b6e 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -646,6 +646,7 @@ "permanent": "Permanent", "schedule": "Schedule", "sensordriven": "Sensor-driven", + "sensoroverride": "Sensor override", "silent": "Silent", "standby": "[%key:common::state::standby%]" } diff --git a/tests/components/vicare/snapshots/test_sensor.ambr b/tests/components/vicare/snapshots/test_sensor.ambr index 4fb6020f4eb809..e18404e5361da6 100644 --- a/tests/components/vicare/snapshots/test_sensor.ambr +++ b/tests/components/vicare/snapshots/test_sensor.ambr @@ -6063,6 +6063,7 @@ 'sensordriven', 'silent', 'forcedlevelfour', + 'sensoroverride', ]), }), 'config_entry_id': , @@ -6107,6 +6108,7 @@ 'sensordriven', 'silent', 'forcedlevelfour', + 'sensoroverride', ]), }), 'context': , @@ -6197,6 +6199,7 @@ 'sensordriven', 'silent', 'forcedlevelfour', + 'sensoroverride', ]), }), 'config_entry_id': , @@ -6241,6 +6244,7 @@ 'sensordriven', 'silent', 'forcedlevelfour', + 'sensoroverride', ]), }), 'context': , @@ -6989,6 +6993,7 @@ 'sensordriven', 'silent', 'forcedlevelfour', + 'sensoroverride', ]), }), 'config_entry_id': , @@ -7033,6 +7038,7 @@ 'sensordriven', 'silent', 'forcedlevelfour', + 'sensoroverride', ]), }), 'context': , From 369a011b58befa02a5930b28fe9c63f167d93ce0 Mon Sep 17 00:00:00 2001 From: Jonas Petersson Date: Sat, 19 Sep 2026 09:18:48 +0200 Subject: [PATCH 15/43] Use glass break device class in HomematicIP Cloud (#182240) Co-authored-by: Jonas Petersson <499325+catellie@users.noreply.github.com> Co-authored-by: Codex --- homeassistant/components/homematicip_cloud/binary_sensor.py | 2 +- tests/components/homematicip_cloud/test_binary_sensor.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 5fe9605f80c593..a4698c6e5d18b9 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -562,7 +562,7 @@ class HomematicipFullFlushLockControllerGlassBreak( ): """Representation of the HomematicIP full flush lock controller glass state.""" - _attr_device_class = BinarySensorDeviceClass.PROBLEM + _attr_device_class = BinarySensorDeviceClass.GLASS_BREAK def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the full flush lock controller glass break sensor.""" diff --git a/tests/components/homematicip_cloud/test_binary_sensor.py b/tests/components/homematicip_cloud/test_binary_sensor.py index ddf03a299dbdef..5122063c6eeaf4 100644 --- a/tests/components/homematicip_cloud/test_binary_sensor.py +++ b/tests/components/homematicip_cloud/test_binary_sensor.py @@ -10,6 +10,7 @@ ) import pytest +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.homematicip_cloud.binary_sensor import ( ATTR_ACCELERATION_SENSOR_MODE, ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION, @@ -30,7 +31,7 @@ ATTR_RSSI_DEVICE, ATTR_SABOTAGE, ) -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -69,6 +70,9 @@ async def test_hmip_full_flush_lock_controller_binary_sensors( "HmIP-FLC", ) assert glass_state.state == STATE_ON + assert ( + glass_state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.GLASS_BREAK + ) assert hmip_device is not None await async_manipulate_test_data(hass, hmip_device, "lockState", "UNLOCKED") From bf34679c7c3d6fa3821c320b5d992b7071992ec7 Mon Sep 17 00:00:00 2001 From: Hariom_Gupta <134048588+HariomGupta0@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:48:55 +0530 Subject: [PATCH 16/43] Remove hardcoded tool name from GetLiveContext prompt (#182111) --- homeassistant/components/homeassistant/llm.py | 4 ++-- tests/helpers/test_llm.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homeassistant/llm.py b/homeassistant/components/homeassistant/llm.py index 2dc81095cf5c00..e533580f30714d 100644 --- a/homeassistant/components/homeassistant/llm.py +++ b/homeassistant/components/homeassistant/llm.py @@ -45,7 +45,7 @@ DYNAMIC_CONTEXT_PROMPT = ( "You ARE equipped to answer questions about the" " current state of\n" - "the home using the `homeassistant__GetLiveContext` tool." + "the home by retrieving live context." " This is a primary function." " Do not state you lack the\n" "functionality if the question requires live data.\n" @@ -59,7 +59,7 @@ ' "What mode is the thermostat in?",' ' "What is the temperature outside?"):\n' " 1. Recognize this requires live data.\n" - " 2. You MUST call `homeassistant__GetLiveContext`." + " 2. You MUST use the provided tool to retrieve live context." " This tool will provide the needed real-time" " information (like temperature from the local" " weather, lock status, etc.).\n" diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index fb75012b45f3e3..3539f34efc129e 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -820,8 +820,8 @@ def create_entity( ) dynamic_context_prompt = ( "You ARE equipped to answer questions about the" - " current state of\nthe home using the" - " `homeassistant__GetLiveContext` tool. This is a primary" + " current state of\nthe home by retrieving" + " live context. This is a primary" " function. Do not state you lack the\n" "functionality if the question requires live" " data.\nIf the user asks about device" @@ -833,7 +833,7 @@ def create_entity( ' "What mode is the thermostat in?", "What' ' is the temperature outside?"):\n' " 1. Recognize this requires live data.\n" - " 2. You MUST call `homeassistant__GetLiveContext`. This" + " 2. You MUST use the provided tool to retrieve live context. This" " tool will provide the needed real-time" " information (like temperature from the local" " weather, lock status, etc.).\n" From e0008c39f87818c2dbd8557aa1ebc8a57f7f3403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Pramn=C3=ADk?= Date: Sat, 19 Sep 2026 09:19:02 +0200 Subject: [PATCH 17/43] Add the Current consumption attribute to the D-Link smart power plug integration (#182038) --- homeassistant/components/dlink/const.py | 1 + homeassistant/components/dlink/switch.py | 8 +++++++- tests/components/dlink/test_switch.py | 2 ++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/dlink/const.py b/homeassistant/components/dlink/const.py index b39cd8be476585..0828c2e4ee130b 100644 --- a/homeassistant/components/dlink/const.py +++ b/homeassistant/components/dlink/const.py @@ -2,6 +2,7 @@ ATTRIBUTION = "Data provided by D-Link" ATTR_TOTAL_CONSUMPTION = "total_consumption" +ATTR_CURRENT_CONSUMPTION = "current_consumption" CONF_USE_LEGACY_PROTOCOL = "use_legacy_protocol" diff --git a/homeassistant/components/dlink/switch.py b/homeassistant/components/dlink/switch.py index 19b25598418216..371cccda2b462e 100644 --- a/homeassistant/components/dlink/switch.py +++ b/homeassistant/components/dlink/switch.py @@ -9,7 +9,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DLinkConfigEntry -from .const import ATTR_TOTAL_CONSUMPTION +from .const import ATTR_CURRENT_CONSUMPTION, ATTR_TOTAL_CONSUMPTION from .entity import DLinkEntity SCAN_INTERVAL = timedelta(minutes=2) @@ -49,9 +49,15 @@ def extra_state_attributes(self) -> dict[str, Any]: except ValueError: total_consumption = None + try: + current_consumption = float(self.data.current_consumption) + except ValueError: + current_consumption = None + return { ATTR_TOTAL_CONSUMPTION: total_consumption, ATTR_TEMPERATURE: temperature, + ATTR_CURRENT_CONSUMPTION: current_consumption, } @property diff --git a/tests/components/dlink/test_switch.py b/tests/components/dlink/test_switch.py index 0460a6a918f3d8..7069a03c75d701 100644 --- a/tests/components/dlink/test_switch.py +++ b/tests/components/dlink/test_switch.py @@ -34,6 +34,7 @@ async def test_switch_state(hass: HomeAssistant, mocked_plug: AsyncMock) -> None assert state.state == STATE_OFF assert state.attributes["total_consumption"] == 1040.0 assert state.attributes["temperature"] == 33 + assert state.attributes["current_consumption"] == 50.0 await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, @@ -67,3 +68,4 @@ async def test_switch_no_value( assert state.state == STATE_OFF assert state.attributes["total_consumption"] is None assert state.attributes["temperature"] is None + assert state.attributes["current_consumption"] is None From d307d49dbfcca28ac05b819711237cd27281f195 Mon Sep 17 00:00:00 2001 From: Evan Severson <208220+eseverson@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:19:09 -0700 Subject: [PATCH 18/43] test(ecobee): use timezone-aware now in switch tests (#181987) --- tests/components/ecobee/test_switch.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/components/ecobee/test_switch.py b/tests/components/ecobee/test_switch.py index 73b57b84b90e86..4815f554041906 100644 --- a/tests/components/ecobee/test_switch.py +++ b/tests/components/ecobee/test_switch.py @@ -1,9 +1,10 @@ """The test for the ecobee thermostat switch module.""" import copy -from datetime import datetime, timedelta +from datetime import timedelta from unittest import mock from unittest.mock import patch +from zoneinfo import ZoneInfo import pytest @@ -15,12 +16,16 @@ ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from . import GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP from .common import setup_platform VENTILATOR_20MIN_ID = "switch.ecobee_ventilator_20m_timer" THERMOSTAT_ID = 0 +THERMOSTAT_TIMEZONE = ZoneInfo( + GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP["location"]["timeZone"] +) @pytest.fixture(name="data") @@ -43,7 +48,7 @@ async def test_ventilator_20min_when_on(hass: HomeAssistant, data) -> None: """Test the ventilator switch goes on.""" data.return_value["settings"]["ventilatorOffDateTime"] = ( - datetime.now() + timedelta(days=1) # pylint: disable=home-assistant-enforce-naive-now + dt_util.now(THERMOSTAT_TIMEZONE) + timedelta(days=1) ).strftime(DATE_FORMAT) with mock.patch("pyecobee.Ecobee.get_thermostat", data): await setup_platform(hass, SWITCH_DOMAIN) @@ -58,7 +63,7 @@ async def test_ventilator_20min_when_off(hass: HomeAssistant, data) -> None: """Test the ventilator switch goes on.""" data.return_value["settings"]["ventilatorOffDateTime"] = ( - datetime.now() - timedelta(days=1) # pylint: disable=home-assistant-enforce-naive-now + dt_util.now(THERMOSTAT_TIMEZONE) - timedelta(days=1) ).strftime(DATE_FORMAT) with mock.patch("pyecobee.Ecobee.get_thermostat", data): await setup_platform(hass, SWITCH_DOMAIN) From 8385ee1a7c3b112cd4b3e24e2280fb2383303d7a Mon Sep 17 00:00:00 2001 From: Manu Date: Sat, 19 Sep 2026 09:19:16 +0200 Subject: [PATCH 19/43] Add image file to testingconfig (#181959) --- tests/components/heos/snapshots/test_media_player.ambr | 2 +- tests/components/media_source/test_helper.py | 6 +++--- tests/components/roku/test_media_player.py | 2 +- tests/testing_config/media/screenshot.jpg | 1 + 4 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 tests/testing_config/media/screenshot.jpg diff --git a/tests/components/heos/snapshots/test_media_player.ambr b/tests/components/heos/snapshots/test_media_player.ambr index cc9a4e023a8c75..6ce76f742ae5e9 100644 --- a/tests/components/heos/snapshots/test_media_player.ambr +++ b/tests/components/heos/snapshots/test_media_player.ambr @@ -68,7 +68,7 @@ 'media_class': 'directory', 'media_content_id': 'media-source://media_source/local/.', 'media_content_type': '', - 'not_shown': 1, + 'not_shown': 2, 'search_media_classes': None, 'thumbnail': None, 'title': 'media', diff --git a/tests/components/media_source/test_helper.py b/tests/components/media_source/test_helper.py index 0f5a236ac6d445..fdbddc5aa94ca1 100644 --- a/tests/components/media_source/test_helper.py +++ b/tests/components/media_source/test_helper.py @@ -25,7 +25,7 @@ async def test_async_browse_media(hass: HomeAssistant) -> None: media = await media_source.async_browse_media(hass, "") assert isinstance(media, media_source.models.BrowseMediaSource) assert media.title == "media" - assert len(media.children) == 2 + assert len(media.children) == 3 # Test content filter media = await media_source.async_browse_media( @@ -37,7 +37,7 @@ async def test_async_browse_media(hass: HomeAssistant) -> None: assert media.title == "media" assert len(media.children) == 1, media.children media.children[0].title = "Epic Sax Guy 10 Hours" - assert media.not_shown == 1 + assert media.not_shown == 2 # Test content filter adds to original not_shown orig_browse = models.MediaSourceItem.async_browse @@ -61,7 +61,7 @@ async def not_shown_browse(self): assert media.title == "media" assert len(media.children) == 1, media.children media.children[0].title = "Epic Sax Guy 10 Hours" - assert media.not_shown == 11 + assert media.not_shown == 12 # Test invalid media content with pytest.raises(BrowseError): diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index 69d81de7656e65..55fdde16316858 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -956,7 +956,7 @@ async def test_media_browse_local_source( assert msg["result"]["title"] == "media" assert msg["result"]["media_class"] == MediaClass.DIRECTORY assert msg["result"]["media_content_type"] == "" - assert len(msg["result"]["children"]) == 2 + assert len(msg["result"]["children"]) == 3 assert msg["result"]["children"][0]["title"] == "Epic Sax Guy 10 Hours.mp4" assert msg["result"]["children"][0]["media_class"] == MediaClass.VIDEO diff --git a/tests/testing_config/media/screenshot.jpg b/tests/testing_config/media/screenshot.jpg new file mode 100644 index 00000000000000..4587b284ac3d64 --- /dev/null +++ b/tests/testing_config/media/screenshot.jpg @@ -0,0 +1 @@ +ÿØÿÛ From 6f3eb0bbe4d58578c2c4c3b3af8e8ea50e9b5161 Mon Sep 17 00:00:00 2001 From: Radek Piekarz Date: Sat, 19 Sep 2026 09:19:23 +0200 Subject: [PATCH 20/43] Fix iCloud device tracker stale location by requesting an active locate on each poll (#181731) --- homeassistant/components/icloud/account.py | 5 +++++ tests/components/icloud/test_account.py | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index 0433ca936c21ac..189da4b39ff400 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -175,6 +175,11 @@ def update_devices(self) -> None: api_devices = {} try: api_devices = self.api.devices + # Since pyicloud 2.3.0 device reads are cache-only and the library + # requests an active locate from Apple only at service creation, so + # explicitly refresh with locate=True to get a fresh GPS fix on + # every poll instead of Apple's cached location. + api_devices.refresh(locate=True) except Exception as err: # noqa: BLE001 _LOGGER.error("Unknown iCloud error: %s", err) self._fetch_interval = 2 diff --git a/tests/components/icloud/test_account.py b/tests/components/icloud/test_account.py index 3ddc13b5d6992a..7ba37caa665008 100644 --- a/tests/components/icloud/test_account.py +++ b/tests/components/icloud/test_account.py @@ -99,6 +99,11 @@ def __init__(self, userinfo, devices) -> None: """Initialize with userinfo and list of device objects.""" self.user_info = userinfo self._devices = devices + self.refresh_calls: list[bool] = [] + + def refresh(self, locate: bool = False) -> None: + """Record refresh calls made by the account.""" + self.refresh_calls.append(locate) def __iter__(self): """Iterate returns device objects (each must have .status(...)).""" @@ -165,3 +170,7 @@ async def test_setup_success_with_devices( assert account.owner_fullname == "user name" assert "johntravolta" in account.family_members_fullname assert account.family_members_fullname["johntravolta"] == "John TRAVOLTA" + # An active locate must be requested on every poll (pyicloud >= 2.3.0 + # only locates at service creation, so the account has to ask for it) + assert mock_icloud_service.devices.refresh_calls == [True] + assert "device1" in account.devices From 92040858c9f5f3d911699c1af32578ae1c8dc46a Mon Sep 17 00:00:00 2001 From: Leonardo Merza Date: Sat, 19 Sep 2026 03:19:31 -0400 Subject: [PATCH 21/43] Use bulk edit API to delete OurGroceries items (#182402) --- homeassistant/components/ourgroceries/manifest.json | 2 +- homeassistant/components/ourgroceries/todo.py | 10 ++++------ requirements_all.txt | 2 +- tests/components/ourgroceries/test_todo.py | 13 ++++++++----- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/ourgroceries/manifest.json b/homeassistant/components/ourgroceries/manifest.json index 6d38b1a885878f..ff242bc7cab6b8 100644 --- a/homeassistant/components/ourgroceries/manifest.json +++ b/homeassistant/components/ourgroceries/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ourgroceries", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["ourgroceries==1.5.4"] + "requirements": ["ourgroceries==1.6.0"] } diff --git a/homeassistant/components/ourgroceries/todo.py b/homeassistant/components/ourgroceries/todo.py index 0241a7bf83d642..4f29f9731b0894 100644 --- a/homeassistant/components/ourgroceries/todo.py +++ b/homeassistant/components/ourgroceries/todo.py @@ -1,8 +1,9 @@ """A todo platform for OurGroceries.""" -import asyncio from typing import Any, override +from ourgroceries import make_delete_item_edit_record + from homeassistant.components.todo import ( TodoItem, TodoItemStatus, @@ -109,11 +110,8 @@ async def async_update_todo_item(self, item: TodoItem) -> None: @override async def async_delete_todo_items(self, uids: list[str]) -> None: """Delete a To-do item.""" - await asyncio.gather( - *[ - self.coordinator.og.remove_item_from_list(self._list_id, uid) - for uid in uids - ] + await self.coordinator.og.edit_items( + self._list_id, [make_delete_item_edit_record(uid) for uid in uids] ) await self.coordinator.async_refresh() diff --git a/requirements_all.txt b/requirements_all.txt index 0566df72a58730..049d465792ab31 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1875,7 +1875,7 @@ orvibo==1.1.2 ouman-eh-800-api==1.0.0 # homeassistant.components.ourgroceries -ourgroceries==1.5.4 +ourgroceries==1.6.0 # homeassistant.components.ovo_energy ovoenergy==3.0.2 diff --git a/tests/components/ourgroceries/test_todo.py b/tests/components/ourgroceries/test_todo.py index d364881b624a97..72d1c44c46bc5a 100644 --- a/tests/components/ourgroceries/test_todo.py +++ b/tests/components/ourgroceries/test_todo.py @@ -217,7 +217,7 @@ async def test_remove_todo_item( assert state assert state.state == "2" - ourgroceries.remove_item_from_list = AsyncMock() + ourgroceries.edit_items = AsyncMock() # Fake API response when state is refreshed after remove _mock_version_id(ourgroceries, 2) ourgroceries.get_list_items.return_value = items_to_shopping_list([]) @@ -229,10 +229,13 @@ async def test_remove_todo_item( target={ATTR_ENTITY_ID: "todo.test_list"}, blocking=True, ) - assert ourgroceries.remove_item_from_list.call_count == 2 - args = ourgroceries.remove_item_from_list.call_args_list - assert args[0].args == ("test_list", "12345") - assert args[1].args == ("test_list", "54321") + ourgroceries.edit_items.assert_called_once_with( + "test_list", + [ + {"editType": "delete", "itemId": "12345"}, + {"editType": "delete", "itemId": "54321"}, + ], + ) await async_update_entity(hass, "todo.test_list") state = hass.states.get("todo.test_list") From 3c3841f21a30a98d343ae46ff51473bb23a049f1 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Sep 2026 09:19:41 +0200 Subject: [PATCH 22/43] Config entry integration methods returns None (#182429) --- homeassistant/config_entries.py | 16 ++++++++-------- homeassistant/loader.py | 6 +++--- .../checkers/type_hints/const.py | 6 +++--- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 80c4792156a93d..40f3f60abd220d 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -982,7 +982,7 @@ async def __async_setup_with_context( with async_start_setup( hass, integration=self.domain, group=self.entry_id, phase=setup_phase ): - result = await component.async_setup_entry(hass, self) + result = await component.async_setup_entry(hass, self) # type: ignore[func-returns-value,assignment] if not isinstance(result, bool): logger.error( # type: ignore[unreachable] @@ -1127,13 +1127,13 @@ async def async_unload( if domain_is_integration: self._async_set_state(hass, ConfigEntryState.UNLOAD_IN_PROGRESS, None) try: - result = await component.async_unload_entry(hass, self) + result = await component.async_unload_entry(hass, self) # type: ignore[func-returns-value] assert isinstance(result, bool) # Only do side effects if we unloaded the integration - if domain_is_integration: - if result: + if domain_is_integration: # type: ignore[unreachable] + if result: # type: ignore[unused-ignore] await self._async_process_on_unload(hass) if hasattr(self, "runtime_data"): object.__delattr__(self, "runtime_data") @@ -1153,7 +1153,7 @@ async def async_unload( hass, ConfigEntryState.FAILED_UNLOAD, str(exc) or "Unknown error" ) return False - return result + return result # type: ignore[unreachable] async def async_remove(self, hass: HomeAssistant) -> None: """Invoke remove callback on component.""" @@ -1281,13 +1281,13 @@ async def async_migrate(self, hass: HomeAssistant) -> bool: ) return False - result = await component.async_migrate_entry(hass, self) + result = await component.async_migrate_entry(hass, self) # type: ignore[func-returns-value] if not isinstance(result, bool): - self.logger.error( # type: ignore[unreachable] + self.logger.error( "%s.async_migrate_entry did not return boolean", self.domain ) return False - if result: + if result: # type: ignore[unreachable] hass.config_entries._async_schedule_save() # noqa: SLF001 return result diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 11a32606464810..d301ce871cf00e 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -384,17 +384,17 @@ class ComponentProtocol(Protocol): async def async_setup_entry( self, hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: + ) -> None: """Set up a config entry.""" async def async_unload_entry( self, hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: + ) -> None: """Unload a config entry.""" async def async_migrate_entry( self, hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: + ) -> None: """Migrate an old config entry.""" async def async_remove_entry( diff --git a/pylint/plugins/pylint_home_assistant/checkers/type_hints/const.py b/pylint/plugins/pylint_home_assistant/checkers/type_hints/const.py index 2285ef9f2e8bf3..6aa7bbcc3b516e 100644 --- a/pylint/plugins/pylint_home_assistant/checkers/type_hints/const.py +++ b/pylint/plugins/pylint_home_assistant/checkers/type_hints/const.py @@ -107,7 +107,7 @@ 0: "HomeAssistant", 1: "ConfigEntry", }, - return_type="bool", + return_type=["bool", "None"], mandatory=True, ), TypeHintMatch( @@ -125,7 +125,7 @@ 0: "HomeAssistant", 1: "ConfigEntry", }, - return_type="bool", + return_type=["bool", "None"], mandatory=True, ), TypeHintMatch( @@ -134,7 +134,7 @@ 0: "HomeAssistant", 1: "ConfigEntry", }, - return_type="bool", + return_type=["bool", "None"], mandatory=True, ), TypeHintMatch( From 6bc9bf89f757de4cb592ecfda541bda884efec6d Mon Sep 17 00:00:00 2001 From: Francesco Sappa <252796783+sappafrancesco@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:19:53 +0200 Subject: [PATCH 23/43] Add required_domains to the cover open/close intents (#181892) --- homeassistant/components/cover/intent.py | 2 ++ tests/components/cover/test_intent.py | 26 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/homeassistant/components/cover/intent.py b/homeassistant/components/cover/intent.py index 2915b8425d3c3a..4cfe7d7c514ece 100644 --- a/homeassistant/components/cover/intent.py +++ b/homeassistant/components/cover/intent.py @@ -18,6 +18,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None: SERVICE_OPEN_COVER, description="Opens a cover", platforms={DOMAIN}, + required_domains={DOMAIN}, device_classes={CoverDeviceClass}, ), ) @@ -29,6 +30,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None: SERVICE_CLOSE_COVER, description="Closes a cover", platforms={DOMAIN}, + required_domains={DOMAIN}, device_classes={CoverDeviceClass}, ), ) diff --git a/tests/components/cover/test_intent.py b/tests/components/cover/test_intent.py index 1af3d512001265..a8ec75038c0c75 100644 --- a/tests/components/cover/test_intent.py +++ b/tests/components/cover/test_intent.py @@ -83,6 +83,32 @@ async def test_close_cover_intent(hass: HomeAssistant, slots: dict[str, Any]) -> assert call.data == {"entity_id": f"{DOMAIN}.garage_door"} +@pytest.mark.parametrize( + ("intent_type", "initial_state"), + [ + (cover_intent.INTENT_OPEN_COVER, "off"), + (cover_intent.INTENT_CLOSE_COVER, "on"), + ], +) +async def test_cover_intent_does_not_match_other_domains( + hass: HomeAssistant, intent_type: str, initial_state: str +) -> None: + """Test HassOpenCover/HassCloseCover do not match an entity of another domain.""" + await cover_intent.async_setup_intents(hass) + + hass.states.async_set("light.garage_door", initial_state) + + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent_type, + {"name": {"value": "garage door"}}, + ) + + assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN + + @pytest.mark.parametrize( ("slots"), [ From fc2e36525f4f6156616bf69c15c479719e0f58e4 Mon Sep 17 00:00:00 2001 From: Dex Date: Sat, 19 Sep 2026 09:19:58 +0200 Subject: [PATCH 24/43] Stop forcing the Blink refresh on every poll (#182639) Co-authored-by: Dex --- homeassistant/components/blink/coordinator.py | 7 +++++- tests/components/blink/test_init.py | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/blink/coordinator.py b/homeassistant/components/blink/coordinator.py index 0464e78f271bee..3dc3572efe6f12 100644 --- a/homeassistant/components/blink/coordinator.py +++ b/homeassistant/components/blink/coordinator.py @@ -62,6 +62,11 @@ async def _async_setup(self): async def _async_update_data(self) -> dict[str, Any]: """Async update wrapper.""" try: - return await self.api.refresh(force=True) + # Do not force the refresh. BlinkPy turns a forced refresh into + # force_cache=True, which makes every sync module re-download media + # it already has, so cached clips are fetched again on every poll. + # The scan interval is far longer than BlinkPy's refresh rate, so a + # normal refresh still happens on schedule. + return await self.api.refresh() except UnauthorizedError as ex: raise ConfigEntryAuthFailed("Blink API authorization failed") from ex diff --git a/tests/components/blink/test_init.py b/tests/components/blink/test_init.py index 6bb6f21d1669f9..484aafe4f4b530 100644 --- a/tests/components/blink/test_init.py +++ b/tests/components/blink/test_init.py @@ -76,6 +76,29 @@ async def test_unload_entry( assert hass.services.has_service(DOMAIN, SERVICE_SAVE_VIDEO) +async def test_scheduled_refresh_is_not_forced( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a scheduled poll does not force a cache refresh. + + BlinkPy propagates a forced refresh as ``force_cache=True`` to the sync + modules, which re-downloads media that is already cached. See #182552. + """ + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_blink_api.refresh.reset_mock() + coordinator = mock_config_entry.runtime_data + await coordinator.async_refresh() + await hass.async_block_till_done() + + mock_blink_api.refresh.assert_awaited_once_with() + + async def test_migrate_V0( hass: HomeAssistant, mock_blink_api: MagicMock, From ce976b95cfd90e498af610a48674675621c9e09c Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Sat, 19 Sep 2026 09:20:03 +0200 Subject: [PATCH 25/43] Isolate CI-only hassfest tooling from the HA wheel index (#181782) --- script/hassfest/docker.py | 12 ++++++++---- script/hassfest/docker/Dockerfile | 12 ++++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index b0c1e1594de7dd..db121e219e7cff 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -107,14 +107,18 @@ def _generate_machine_dockerfile( apk add --no-cache libturbojpeg \ # Install uv at the version pinned in the requirements file && pip install --no-cache-dir "uv==$(awk -F'==' '/^uv==/{{print $2}}' /usr/src/homeassistant/requirements.txt)" \ - && uv pip install \ + # Install CI-only tooling from PyPI, bypassing the Home Assistant wheel index + && UV_EXTRA_INDEX_URL="" uv pip install \ --no-build \ --no-cache \ - -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ - -r /usr/src/homeassistant/requirements.txt \ "pipdeptree==$(awk -F'==' '/^pipdeptree==/{{print $2}}' /tmp/requirements_test.txt)" \ "tqdm==$(awk -F'==' '/^tqdm==/{{print $2}}' /tmp/requirements_test.txt)" \ - "ruff==$(awk -F'==' '/^ruff==/{{print $2}}' /tmp/requirements_test_pre_commit.txt)" + "ruff==$(awk -F'==' '/^ruff==/{{print $2}}' /tmp/requirements_test_pre_commit.txt)" \ + && uv pip install \ + --no-build \ + --no-cache \ + -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ + -r /usr/src/homeassistant/requirements.txt LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 0d808eb8ae601d..4024b9d1896b18 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -22,14 +22,18 @@ RUN --mount=type=tmpfs,target=/tmp \ apk add --no-cache libturbojpeg \ # Install uv at the version pinned in the requirements file && pip install --no-cache-dir "uv==$(awk -F'==' '/^uv==/{print $2}' /usr/src/homeassistant/requirements.txt)" \ - && uv pip install \ + # Install CI-only tooling from PyPI, bypassing the Home Assistant wheel index + && UV_EXTRA_INDEX_URL="" uv pip install \ --no-build \ --no-cache \ - -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ - -r /usr/src/homeassistant/requirements.txt \ "pipdeptree==$(awk -F'==' '/^pipdeptree==/{print $2}' /tmp/requirements_test.txt)" \ "tqdm==$(awk -F'==' '/^tqdm==/{print $2}' /tmp/requirements_test.txt)" \ - "ruff==$(awk -F'==' '/^ruff==/{print $2}' /tmp/requirements_test_pre_commit.txt)" + "ruff==$(awk -F'==' '/^ruff==/{print $2}' /tmp/requirements_test_pre_commit.txt)" \ + && uv pip install \ + --no-build \ + --no-cache \ + -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ + -r /usr/src/homeassistant/requirements.txt LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From 2af8764634f72cef0794cafedd62c20d9e5bd85b Mon Sep 17 00:00:00 2001 From: Francesco Sappa <252796783+sappafrancesco@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:22:03 +0200 Subject: [PATCH 26/43] Add required_domains to the cover open/close intents (#181892) From f6e830c7ecd52c80b3590a8de3d58f42dff330c3 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 19 Sep 2026 09:22:44 +0200 Subject: [PATCH 27/43] Update IQS to gold for Midea (#182580) Co-authored-by: Franck Nijhof --- homeassistant/components/midea/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/midea/manifest.json b/homeassistant/components/midea/manifest.json index 4170de82e7ddcf..2a110ee733a11d 100644 --- a/homeassistant/components/midea/manifest.json +++ b/homeassistant/components/midea/manifest.json @@ -12,6 +12,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["midealocal"], - "quality_scale": "bronze", + "quality_scale": "gold", "requirements": ["midea-local==12.1.0"] } From 94432b5b66f40af404aa4014a04c6ef75df30b0f Mon Sep 17 00:00:00 2001 From: Thomas Gregg Date: Sat, 19 Sep 2026 09:34:33 +0200 Subject: [PATCH 28/43] Preserve Ring listener credentials during reauth and reconfigure (#182033) --- homeassistant/components/ring/config_flow.py | 12 ++++++++---- tests/components/ring/test_config_flow.py | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py index 0a4089d597a478..5150a5953aa6e1 100644 --- a/homeassistant/components/ring/config_flow.py +++ b/homeassistant/components/ring/config_flow.py @@ -186,12 +186,14 @@ async def async_step_reauth_confirm( _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - data = { + data_updates = { CONF_USERNAME: user_input[CONF_USERNAME], CONF_TOKEN: token, CONF_DEVICE_ID: self.hardware_id, } - return self.async_update_reload_and_abort(reauth_entry, data=data) + return self.async_update_reload_and_abort( + reauth_entry, data_updates=data_updates + ) return self.async_show_form( step_id="reauth_confirm", @@ -229,12 +231,14 @@ async def async_step_reconfigure( _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - data = { + data_updates = { CONF_USERNAME: username, CONF_TOKEN: token, CONF_DEVICE_ID: self.hardware_id, } - return self.async_update_reload_and_abort(reconfigure_entry, data=data) + return self.async_update_reload_and_abort( + reconfigure_entry, data_updates=data_updates + ) return self.async_show_form( step_id="reconfigure", diff --git a/tests/components/ring/test_config_flow.py b/tests/components/ring/test_config_flow.py index f5763e0340e851..f038d440b0c20f 100644 --- a/tests/components/ring/test_config_flow.py +++ b/tests/components/ring/test_config_flow.py @@ -7,6 +7,7 @@ from homeassistant import config_entries from homeassistant.components.ring import DOMAIN +from homeassistant.components.ring.const import CONF_LISTEN_CREDENTIALS from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -128,6 +129,14 @@ async def test_reauth( mock_ring_auth: Mock, ) -> None: """Test reauth flow.""" + listen_credentials = {"gcm": {"android_id": "stored-android-id"}} + hass.config_entries.async_update_entry( + mock_added_config_entry, + data={ + **mock_added_config_entry.data, + CONF_LISTEN_CREDENTIALS: listen_credentials, + }, + ) mock_added_config_entry.async_start_reauth(hass) await hass.async_block_till_done() @@ -165,6 +174,7 @@ async def test_reauth( CONF_DEVICE_ID: MOCK_HARDWARE_ID, CONF_USERNAME: "foo@bar.com", CONF_TOKEN: "new-foobar", + CONF_LISTEN_CREDENTIALS: listen_credentials, } assert len(mock_setup_entry.mock_calls) == 1 @@ -311,6 +321,15 @@ async def test_reconfigure( ) -> None: """Test the reconfigure config flow.""" + listen_credentials = {"gcm": {"android_id": "stored-android-id"}} + hass.config_entries.async_update_entry( + mock_added_config_entry, + data={ + **mock_added_config_entry.data, + CONF_LISTEN_CREDENTIALS: listen_credentials, + }, + ) + assert mock_added_config_entry.data[CONF_DEVICE_ID] == MOCK_HARDWARE_ID result = await mock_added_config_entry.start_reconfigure_flow(hass) @@ -328,6 +347,7 @@ async def test_reconfigure( assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "reconfigure_successful" assert mock_added_config_entry.data[CONF_DEVICE_ID] == "new-hardware-id" + assert mock_added_config_entry.data[CONF_LISTEN_CREDENTIALS] == listen_credentials @pytest.mark.parametrize( From e0010d33567d169df5b11c8a0b34858acd47d41d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:44:30 +0200 Subject: [PATCH 29/43] Update ical to 14.2.0 (#182658) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- homeassistant/components/google/manifest.json | 2 +- homeassistant/components/local_calendar/manifest.json | 2 +- homeassistant/components/local_todo/manifest.json | 2 +- homeassistant/components/remote_calendar/manifest.json | 2 +- requirements_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 22fc0bdd722233..2a18c8dcb256e5 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -8,5 +8,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==9.1.1", "oauth2client==4.1.3", "ical==14.1.1"] + "requirements": ["gcal-sync==9.1.1", "oauth2client==4.1.3", "ical==14.2.0"] } diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 58776c82aaecd4..6c8b7442005701 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==14.1.1"] + "requirements": ["ical==14.2.0"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 622d427924aef1..2b5e7ce522c665 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==14.1.1"] + "requirements": ["ical==14.2.0"] } diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index e7e53aba98aa9b..84fc117b01de56 100644 --- a/homeassistant/components/remote_calendar/manifest.json +++ b/homeassistant/components/remote_calendar/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["ical"], "quality_scale": "silver", - "requirements": ["ical==14.1.1"] + "requirements": ["ical==14.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 049d465792ab31..3c32b98e0ca09e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1369,7 +1369,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==14.1.1 +ical==14.2.0 # homeassistant.components.caldav icalendar==6.3.1 From 94594f599013ad3340c1a8694e7b9cec674ad53f Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 19 Sep 2026 17:46:48 +1000 Subject: [PATCH 30/43] Reset Tesla Fleet energy history interval after rate limit backoff (#182453) --- .../components/tesla_fleet/coordinator.py | 2 ++ tests/components/tesla_fleet/test_init.py | 35 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 0fa6d7e05e9a4e..de1806ab01c0fa 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -322,6 +322,8 @@ async def async_config_entry_first_refresh(self) -> None: async def _async_update_data(self) -> dict[str, Any]: """Update energy site history data using Tesla Fleet API.""" + self.update_interval = ENERGY_HISTORY_INTERVAL + try: data = (await self.api.energy_history(TeslaEnergyPeriod.DAY))["response"] except RateLimited as e: diff --git a/tests/components/tesla_fleet/test_init.py b/tests/components/tesla_fleet/test_init.py index e660cb767de180..991cc934a7bb9a 100644 --- a/tests/components/tesla_fleet/test_init.py +++ b/tests/components/tesla_fleet/test_init.py @@ -944,6 +944,41 @@ async def test_energy_history_refresh_ratelimited( assert mock_energy_history.call_count == 2 +async def test_energy_history_refresh_ratelimited_not_sticky( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + mock_energy_history: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a 429 backoff only delays the next energy history refresh.""" + + await setup_platform(hass, normal_config_entry) + + after = ENERGY_HISTORY_INTERVAL + timedelta(seconds=10) + # The library passes the raw Retry-After header through as a string + mock_energy_history.side_effect = RateLimited( + {"after": str(int(after.total_seconds()))} + ) + freezer.tick(ENERGY_HISTORY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_energy_history.call_count == 1 + + mock_energy_history.side_effect = None + freezer.tick(after) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_energy_history.call_count == 2 + + freezer.tick(ENERGY_HISTORY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_energy_history.call_count == 3 + + async def test_init_region_issue( hass: HomeAssistant, normal_config_entry: MockConfigEntry, From 36cd5c69e791d6974c3d6d1977e387e9cceade99 Mon Sep 17 00:00:00 2001 From: Jonas Petersson Date: Sat, 19 Sep 2026 09:48:33 +0200 Subject: [PATCH 31/43] Use glass break device class in SimpliSafe (#182241) Co-authored-by: Codex --- .../components/simplisafe/binary_sensor.py | 2 +- tests/components/simplisafe/test_init.py | 40 ++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py index 2d50d85ee55ee0..8b32548a5fdbd9 100644 --- a/homeassistant/components/simplisafe/binary_sensor.py +++ b/homeassistant/components/simplisafe/binary_sensor.py @@ -43,7 +43,7 @@ TRIGGERED_SENSOR_TYPES = { DeviceTypes.CARBON_MONOXIDE: BinarySensorDeviceClass.GAS, DeviceTypes.ENTRY: BinarySensorDeviceClass.DOOR, - DeviceTypes.GLASS_BREAK: BinarySensorDeviceClass.SAFETY, + DeviceTypes.GLASS_BREAK: BinarySensorDeviceClass.GLASS_BREAK, DeviceTypes.LEAK: BinarySensorDeviceClass.MOISTURE, DeviceTypes.MOTION: BinarySensorDeviceClass.MOTION, DeviceTypes.MOTION_V2: BinarySensorDeviceClass.MOTION, diff --git a/tests/components/simplisafe/test_init.py b/tests/components/simplisafe/test_init.py index f80640d49ed03b..acfe41aa24a8e8 100644 --- a/tests/components/simplisafe/test_init.py +++ b/tests/components/simplisafe/test_init.py @@ -1,5 +1,6 @@ """Define tests for SimpliSafe setup.""" +import copy from unittest.mock import AsyncMock, Mock from freezegun.api import FrozenDateTimeFactory @@ -10,14 +11,19 @@ RequestError, SimplipyError, ) +from simplipy.system.v3 import SystemV3 from simplipy.websocket import WebsocketEvent +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) from homeassistant.components.simplisafe import DOMAIN from homeassistant.components.simplisafe.coordinator import DEFAULT_SCAN_INTERVAL from homeassistant.config_entries import SOURCE_REAUTH -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, async_fire_time_changed @@ -75,6 +81,36 @@ async def test_base_station_model_is_string( assert isinstance(device.model, str) +async def test_glass_break_device_class( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + patch_simplisafe_api: None, + system_v3: SystemV3, +) -> None: + """Test the glass break binary sensor device class.""" + system_v3.sensor_data = copy.deepcopy(system_v3.sensor_data) + system_v3.sensor_data["glass"] = { + "serial": "glass", + "type": 6, + "name": "Living Room", + "status": {"triggered": False}, + "flags": {"lowBattery": False, "offline": False}, + } + system_v3.generate_device_objects() + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = entity_registry.async_get_entity_id( + BINARY_SENSOR_DOMAIN, DOMAIN, "glass" + ) + assert entity_id + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.GLASS_BREAK + + async def test_coordinator_update_triggers_reauth_on_invalid_credentials( hass: HomeAssistant, config_entry: MockConfigEntry, From 54daa0f90747a5230f8bd4e2c66d92d8da13d70e Mon Sep 17 00:00:00 2001 From: mvn23 Date: Sat, 19 Sep 2026 09:49:32 +0200 Subject: [PATCH 32/43] Add hot water push functionality to opentherm_gw (#182299) --- .../components/opentherm_gw/button.py | 9 ++++- .../components/opentherm_gw/strings.json | 3 ++ tests/components/opentherm_gw/test_button.py | 34 +++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/opentherm_gw/button.py b/homeassistant/components/opentherm_gw/button.py index efd4198aa94325..d40e36450e4162 100644 --- a/homeassistant/components/opentherm_gw/button.py +++ b/homeassistant/components/opentherm_gw/button.py @@ -18,6 +18,7 @@ from . import OpenThermGatewayHub from .const import ( + BOILER_DEVICE_DESCRIPTION, DATA_GATEWAYS, DATA_OPENTHERM_GW, GATEWAY_DEVICE_DESCRIPTION, @@ -42,10 +43,17 @@ class OpenThermButtonEntityDescription( device_description=THERMOSTAT_DEVICE_DESCRIPTION, action=lambda hub: hub.set_room_setpoint(0), ), + OpenThermButtonEntityDescription( + key="hot_water_push", + translation_key="hot_water_push", + device_description=BOILER_DEVICE_DESCRIPTION, + action=lambda hub: hub.gateway.set_hot_water_ovrd("P"), + ), OpenThermButtonEntityDescription( key="restart_button", device_class=ButtonDeviceClass.RESTART, device_description=GATEWAY_DEVICE_DESCRIPTION, + entity_category=EntityCategory.CONFIG, action=lambda hub: hub.gateway.set_mode(gw_vars.OTGW_MODE_RESET), ), ) @@ -67,7 +75,6 @@ async def async_setup_entry( class OpenThermButton(OpenThermEntity, ButtonEntity): """Representation of an OpenTherm button.""" - _attr_entity_category = EntityCategory.CONFIG entity_description: OpenThermButtonEntityDescription @override diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index 7b6ee70e3c62f6..3af738b9803ba5 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -160,6 +160,9 @@ "button": { "cancel_room_setpoint_override": { "name": "Cancel room setpoint override" + }, + "hot_water_push": { + "name": "Heat hot water once" } }, "select": { diff --git a/tests/components/opentherm_gw/test_button.py b/tests/components/opentherm_gw/test_button.py index 71e453789a881f..cd252c7a2b8fcf 100644 --- a/tests/components/opentherm_gw/test_button.py +++ b/tests/components/opentherm_gw/test_button.py @@ -50,6 +50,40 @@ async def test_cancel_room_setpoint_override_button( mock_pyotgw.return_value.set_target_temp.assert_awaited_once_with(0, True) +async def test_hot_water_push_button( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_pyotgw: MagicMock, +) -> None: + """Test hot water push button.""" + + mock_pyotgw.return_value.set_hot_water_ovrd = AsyncMock(return_value="P") + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + button_entity_id := entity_registry.async_get_entity_id( + BUTTON_DOMAIN, + DOMAIN, + f"{mock_config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.BOILER}-hot_water_push", + ) + ) is not None + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: button_entity_id, + }, + blocking=True, + ) + + mock_pyotgw.return_value.set_hot_water_ovrd.assert_awaited_once_with("P") + + async def test_restart_button( hass: HomeAssistant, entity_registry: er.EntityRegistry, From 76920130f840e5aa80de669eef9ebfa04a3b6fc5 Mon Sep 17 00:00:00 2001 From: Jonas Petersson Date: Sat, 19 Sep 2026 09:50:22 +0200 Subject: [PATCH 33/43] Use glass break device class in Z-Wave JS (#182243) Co-authored-by: Codex --- .../components/zwave_js/binary_sensor.py | 2 +- .../components/zwave_js/test_binary_sensor.py | 46 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index f3c5b29de59fdf..de4c8b735dd276 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -300,7 +300,7 @@ class LegacyDoorStateRepairDescription: # NotificationType 7: Home Security - State Id's 5, 6 (glass breakage) key=NOTIFICATION_HOME_SECURITY, states={5, 6}, - device_class=BinarySensorDeviceClass.SAFETY, + device_class=BinarySensorDeviceClass.GLASS_BREAK, ), NotificationZWaveJSEntityDescription( # NotificationType 7: Home Security - State Id's 7, 8 (motion) diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index c6d942577cb3c8..26027acf235df8 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -152,6 +152,25 @@ def _add_lock_state_notification_states(node_state: dict[str, Any]) -> dict[str, return updated_state +def _add_glass_break_notification_states( + node_state: dict[str, Any], +) -> dict[str, Any]: + """Return a node state with Home Security glass break notification states.""" + updated_state = copy.deepcopy(node_state) + for value_data in updated_state["values"]: + if ( + value_data.get("commandClass") == 113 + and value_data.get("property") == "Home Security" + ): + value_data["metadata"]["states"] = { + "0": "idle", + "5": "Glass breakage detected (location provided)", + "6": "Glass breakage detected", + } + break + return updated_state + + def _set_opening_state_metadata_states( node_state: dict[str, Any], states: dict[str, str] ) -> dict[str, Any]: @@ -330,6 +349,33 @@ async def test_notification_sensor( assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC +async def test_glass_break_notification_sensor( + hass: HomeAssistant, + client: MagicMock, + lock_schlage_be469_state: NodeDataType, +) -> None: + """Test the glass break notification sensor device class.""" + node = Node( + client, + _add_glass_break_notification_states(lock_schlage_be469_state), + ) + client.driver.controller.nodes[node.node_id] = node + + entry = MockConfigEntry(domain=DOMAIN, data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + glass_break_states = [ + state + for state in hass.states.async_all(BINARY_SENSOR_DOMAIN) + if state.attributes.get(ATTR_DEVICE_CLASS) + == BinarySensorDeviceClass.GLASS_BREAK + ] + assert len(glass_break_states) == 2 + assert all(state.state == STATE_OFF for state in glass_break_states) + + @pytest.mark.parametrize( ("entity_id", "device_class"), [ From f31b3e256750109417e15b51b54692ace93bc6c8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 19 Sep 2026 09:51:17 +0200 Subject: [PATCH 34/43] Fix Matter fan and light dividing by zero on out-of-spec device ranges (#182645) --- homeassistant/components/matter/fan.py | 6 ++++- homeassistant/components/matter/light.py | 25 +++++++++++------- tests/components/matter/test_fan.py | 21 +++++++++++++++ tests/components/matter/test_light.py | 33 ++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/matter/fan.py b/homeassistant/components/matter/fan.py index 4981f7aadc4c53..5439b1105fbda9 100644 --- a/homeassistant/components/matter/fan.py +++ b/homeassistant/components/matter/fan.py @@ -270,9 +270,13 @@ def _calculate_features( # does not leave a stale speed_count / percentage_step. self._attr_speed_count = 100 if feature_map & FanControlFeature.kMultiSpeed: - self._attr_speed_count = int( + speed_max = int( self.get_matter_attribute_value(clusters.FanControl.Attributes.SpeedMax) ) + # the step size divides by the speed count, so a device reporting + # no speeds keeps the default + if speed_max > 0: + self._attr_speed_count = speed_max if feature_map & FanControlFeature.kRocking: # NOTE: the Matter model allows that a device can have multiple/different # rock directions while HA doesn't allow this in the entity model. diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 97d601c835b10b..206db521b9a44c 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -84,6 +84,19 @@ ) +def _level_range(level_control: clusters.LevelControl) -> tuple[int, int]: + """Return the level range of the device. + + Brightness scaling divides by the width of the range, so a device that + reports a range without width gets the default range instead. + """ + min_level = level_control.minLevel or 1 + max_level = level_control.maxLevel or 254 + if max_level <= min_level: + return (1, 254) + return (min_level, max_level) + + async def async_setup_entry( hass: HomeAssistant, config_entry: MatterConfigEntry, @@ -177,13 +190,7 @@ async def _set_brightness(self, brightness: int, transition: float = 0.0) -> Non assert level_control is not None - level = round( - renormalize( - brightness, - (0, 255), - (level_control.minLevel or 1, level_control.maxLevel or 254), - ) - ) + level = round(renormalize(brightness, (0, 255), _level_range(level_control))) await self.send_device_command( clusters.LevelControl.Commands.MoveToLevelWithOnOff( @@ -280,9 +287,7 @@ def _get_brightness(self) -> int | None: return round( renormalize( - level_control.currentLevel, - (level_control.minLevel or 1, level_control.maxLevel or 254), - (0, 255), + level_control.currentLevel, _level_range(level_control), (0, 255) ) ) diff --git a/tests/components/matter/test_fan.py b/tests/components/matter/test_fan.py index 67e5f9614230d7..ff471255e2f0df 100644 --- a/tests/components/matter/test_fan.py +++ b/tests/components/matter/test_fan.py @@ -504,3 +504,24 @@ async def test_fan_set_percentage_without_multispeed( attribute_path="1/514/2", value=75, ) + + +@pytest.mark.parametrize("node_fixture", ["mock_air_purifier"]) +@pytest.mark.parametrize("attributes", [{"1/514/4": 0}]) +async def test_fan_without_speed_steps( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test a fan reporting no speed steps keeps the default step size.""" + entity_id = "fan.mock_air_purifier" + state = hass.states.get(entity_id) + assert state + assert state.attributes["percentage_step"] == 1 + + # a proper speed count is picked up when the features are recalculated + set_node_attribute(matter_node, 1, 514, 4, 5) + set_node_attribute(matter_node, 1, 514, 65532, 47) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state.attributes["percentage_step"] == 20 diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index 4b67e8c7b8c745..0275cd53019f9b 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -565,3 +565,36 @@ async def test_light_unexpected_color_mode( transitionTime=0, ), ) + + +@pytest.mark.parametrize("node_fixture", ["mock_dimmable_light"]) +async def test_dimmable_light_without_level_range( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test a light reporting a level range without width uses the default range.""" + entity_id = "light.mock_dimmable_light" + set_node_attribute(matter_node, 1, 8, 2, 5) + set_node_attribute(matter_node, 1, 8, 3, 5) + set_node_attribute(matter_node, 1, 8, 0, 5) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert state.attributes["brightness"] == 4 + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": entity_id, "brightness": 128}, + blocking=True, + ) + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.LevelControl.Commands.MoveToLevelWithOnOff( + level=128, + transitionTime=0, + ), + ) From d973cfea4d9509066702ed59ccc2782dfe78fad9 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Sat, 19 Sep 2026 03:55:39 -0400 Subject: [PATCH 35/43] Add delay_on and delay_off when configuring a binary sensor template helpers (#181203) --- .../components/template/binary_sensor.py | 11 +- .../components/template/config_flow.py | 14 +- .../components/template/strings.json | 16 +- .../components/template/template_entity.py | 12 ++ .../components/template/test_binary_sensor.py | 141 +++++++++++++++++- 5 files changed, 187 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index bf826b6526951c..21155a3a0a2838 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -222,12 +222,21 @@ def _update_state(self, result): def _set_state(_): """Set state of template binary sensor.""" self._attr_is_on = state - self.async_write_ha_state() + if self._preview_callback: + self._async_preview_update() + else: + self.async_write_ha_state() delay = (self._delay_on if state else self._delay_off).total_seconds() # state with delay. Cancelled if template result changes. self._delay_cancel = async_call_later(self.hass, delay, _set_state) + @override + def _call_on_remove_callbacks(self): + if self._delay_cancel: + self._delay_cancel() + return super()._call_on_remove_callbacks() + @dataclass class AutoOffExtraStoredData(ExtraStoredData): diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 84901194565b21..a3569dbc179dc6 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -52,7 +52,11 @@ TemplateCodeFormat, async_create_preview_alarm_control_panel, ) -from .binary_sensor import async_create_preview_binary_sensor +from .binary_sensor import ( + CONF_DELAY_OFF, + CONF_DELAY_ON, + async_create_preview_binary_sensor, +) from .climate import ( CONF_CURRENT_TEMPERATURE, CONF_HVAC_ACTION, @@ -198,6 +202,14 @@ def generate_schema(domain: str, flow_type: str) -> probatio.Schema: selector.DeviceClassSelectorConfig(domain=Platform.BINARY_SENSOR), ), } + additional_options |= { + probatio.Optional(CONF_DELAY_ON): selector.DurationSelector( + selector.DurationSelectorConfig(allow_negative=False) + ), + probatio.Optional(CONF_DELAY_OFF): selector.DurationSelector( + selector.DurationSelectorConfig(allow_negative=False) + ), + } if domain == Platform.BUTTON: schema |= { diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 99bfe69c5301f2..dcdb2f50c82c1c 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -70,10 +70,14 @@ "sections": { "additional_options": { "data": { - "availability": "[%key:component::template::common::availability%]" + "availability": "[%key:component::template::common::availability%]", + "delay_off": "Delay off", + "delay_on": "Delay on" }, "data_description": { - "availability": "[%key:component::template::common::availability_description%]" + "availability": "[%key:component::template::common::availability_description%]", + "delay_off": "The amount of time the template state must not be met before this sensor switches to `off`.", + "delay_on": "The amount of time the template state must be met before this sensor switches to `on`." }, "name": "[%key:component::template::common::additional_options%]" } @@ -712,10 +716,14 @@ "sections": { "additional_options": { "data": { - "availability": "[%key:component::template::common::availability%]" + "availability": "[%key:component::template::common::availability%]", + "delay_off": "[%key:component::template::config::step::binary_sensor::sections::additional_options::data::delay_off%]", + "delay_on": "[%key:component::template::config::step::binary_sensor::sections::additional_options::data::delay_on%]" }, "data_description": { - "availability": "[%key:component::template::common::availability_description%]" + "availability": "[%key:component::template::common::availability_description%]", + "delay_off": "[%key:component::template::config::step::binary_sensor::sections::additional_options::data_description::delay_off%]", + "delay_on": "[%key:component::template::config::step::binary_sensor::sections::additional_options::data_description::delay_on%]" }, "name": "[%key:component::template::common::additional_options%]" } diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 967a310bf32fc4..bd92181ff516aa 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -453,6 +453,18 @@ def _handle_results( self._preview_callback(None, None, None, str(errors[-1])) return + self._async_preview_update() + + @callback + def _async_preview_update(self) -> None: + """Send an updated state to the preview callback.""" + if not self._preview_callback: + return + + if not self._template_result_info: + self._preview_callback(None, None, None, "Preview not ready") + return + try: calculated_state = self._async_calculate_state() validate_state(calculated_state.state) diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 2cd53265f05ac0..a628c53fa25810 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -10,7 +10,7 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant import setup +from homeassistant import config_entries, setup from homeassistant.components import binary_sensor, template from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -21,10 +21,12 @@ STATE_UNKNOWN, ) from homeassistant.core import Context, CoreState, HomeAssistant, State +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util from .conftest import ( RESTORE_STATE_SAVED_ATTRIBUTES, @@ -598,6 +600,143 @@ async def test_delay_off(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> assert hass.states.get(TEST_BINARY_SENSOR.entity_id).state == STATE_OFF +@pytest.mark.parametrize( + ("advanced_input", "expected_advanced_options", "expected_state"), + [ + ( + {"delay_on": {"seconds": 5}}, + {"delay_on": {"seconds": 5.0}}, + STATE_UNKNOWN, + ), + ( + {"delay_off": {"minutes": 1}}, + {"delay_off": {"minutes": 1.0}}, + STATE_ON, + ), + ( + {"delay_on": {"seconds": 5}, "delay_off": {"minutes": 1}}, + {"delay_on": {"seconds": 5.0}, "delay_off": {"minutes": 1.0}}, + STATE_UNKNOWN, + ), + ], +) +async def test_config_flow_binary_sensor_delay_options( + hass: HomeAssistant, + advanced_input: dict[str, Any], + expected_advanced_options: dict[str, Any], + expected_state: str, +) -> None: + """Test delay options in the binary sensor config flow.""" + result = await hass.config_entries.flow.async_init( + template.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "binary_sensor"}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "binary_sensor" + + with patch( + "homeassistant.components.template.async_setup_entry", + wraps=template.async_setup_entry, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "name": "My template", + "state": "{{ true }}", + "additional_options": advanced_input, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "My template" + assert result["data"] == {} + assert result["options"] == { + "name": "My template", + "template_type": "binary_sensor", + "state": "{{ true }}", + "additional_options": expected_advanced_options, + } + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(template.DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == { + "name": "My template", + "template_type": "binary_sensor", + "state": "{{ true }}", + "additional_options": expected_advanced_options, + } + state = hass.states.get("binary_sensor.my_template") + assert state.state == expected_state + + +async def test_config_flow_preview_binary_sensor_delay( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview with a delayed binary sensor.""" + client = await hass_ws_client(hass) + + hass.states.async_set("binary_sensor.available", "on") + hass.states.async_set("binary_sensor.one", "off") + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + template.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "binary_sensor"}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "binary_sensor" + assert result["preview"] == "template" + + await client.send_json_auto_id( + { + "type": "template/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": { + "name": "My template", + "state": "{{ is_state('binary_sensor.one', 'on') }}", + "additional_options": { + "availability": "{{ True }}", + "delay_on": {"seconds": 1}, + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"]["state"] == "off" + + hass.states.async_set("binary_sensor.one", "on") + await hass.async_block_till_done() + + msg = await client.receive_json() + assert msg["event"]["state"] == "off" + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + + msg = await client.receive_json() + assert msg["event"]["state"] == "on" + + @pytest.mark.parametrize( ("count", "state_template", "extra_config"), [ From 279b124671e98b9dd1482365491392737a3c9277 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 19 Sep 2026 10:08:00 +0200 Subject: [PATCH 36/43] Resolve integration brand images before brand images (#182217) Co-authored-by: Claude Opus 5 --- homeassistant/components/brands/__init__.py | 57 +++++---- tests/components/brands/test_init.py | 124 +++++++++++++++++--- 2 files changed, 142 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/brands/__init__.py b/homeassistant/components/brands/__init__.py index a1a6ab22e2bdc7..544c2440bf4ebb 100644 --- a/homeassistant/components/brands/__init__.py +++ b/homeassistant/components/brands/__init__.py @@ -155,32 +155,38 @@ async def _serve_from_custom_integration( return None - async def _serve_from_cache_or_cdn( + async def _get_image_data( self, cdn_path: str, cache_subpath: str, - *, - fallback_placeholder: bool = True, - ) -> web.Response: - """Serve from disk cache, fetching from CDN if needed.""" + ) -> bytes | None: + """Read image data from the disk cache, fetching from CDN if needed.""" cache_path = self._cache_dir / cache_subpath - now = time.time() - # Try disk cache result = await self._hass.async_add_executor_job( _read_cached_file_with_marker, cache_path ) - if result is not None: - data, mtime = result - # Schedule background refresh if stale - if now - mtime > CACHE_TTL: - self._hass.async_create_background_task( - self._fetch_and_cache(cdn_path, cache_path), - f"brands_refresh_{cache_subpath}", - ) - else: - # Cache miss - fetch from CDN - data = await self._fetch_and_cache(cdn_path, cache_path) + if result is None: + return await self._fetch_and_cache(cdn_path, cache_path) + + data, mtime = result + # Schedule background refresh if stale + if time.time() - mtime > CACHE_TTL: + self._hass.async_create_background_task( + self._fetch_and_cache(cdn_path, cache_path), + f"brands_refresh_{cache_subpath}", + ) + return data + + async def _serve_from_cache_or_cdn( + self, + cdn_path: str, + cache_subpath: str, + *, + fallback_placeholder: bool = True, + ) -> web.Response: + """Serve from disk cache, fetching from CDN if needed.""" + data = await self._get_image_data(cdn_path, cache_subpath) if data is None: if fallback_placeholder: @@ -260,10 +266,21 @@ async def get( ) is not None: return response - # 2. Try cache / CDN (always use direct path for proper 404 caching) + # 2. Try the integration image. Direct paths are used instead of the + # "_/" namespace so real 404s can be cached as markers. + if ( + data := await self._get_image_data( + cdn_path=f"{domain}/{image}", + cache_subpath=f"integrations/{domain}/{image}", + ) + ) is not None: + return self._build_response(data) + + # 3. Fall back to the brand image, which is cached separately so the + # integration 404 marker is preserved. return await self._serve_from_cache_or_cdn( cdn_path=f"brands/{domain}/{image}", - cache_subpath=f"integrations/{domain}/{image}", + cache_subpath=f"brands/{domain}/{image}", fallback_placeholder=use_placeholder, ) diff --git a/tests/components/brands/test_init.py b/tests/components/brands/test_init.py index 5e13a9bf909db6..6eb4ccebd7e192 100644 --- a/tests/components/brands/test_init.py +++ b/tests/components/brands/test_init.py @@ -26,6 +26,7 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator FAKE_PNG = b"\x89PNG\r\n\x1a\nfakeimagedata" +BRAND_PNG = b"\x89PNG\r\n\x1a\nfakebranddata" @pytest.fixture(autouse=True) @@ -73,7 +74,7 @@ async def test_integration_view_serves_from_cdn( ) -> None: """Test serving an integration brand image from the CDN.""" aioclient_mock.get( - f"{BRANDS_CDN_URL}/brands/hue/icon.png", + f"{BRANDS_CDN_URL}/hue/icon.png", content=FAKE_PNG, ) @@ -85,12 +86,68 @@ async def test_integration_view_serves_from_cdn( assert await resp.read() == FAKE_PNG +async def test_integration_view_prefers_integration_over_brand( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that the integration image wins over a brand image of the same name.""" + aioclient_mock.get( + f"{BRANDS_CDN_URL}/google/icon.png", + content=FAKE_PNG, + ) + aioclient_mock.get( + f"{BRANDS_CDN_URL}/brands/google/icon.png", + content=BRAND_PNG, + ) + + client = await hass_client() + resp = await client.get("/api/brands/integration/google/icon.png") + + assert resp.status == HTTPStatus.OK + assert await resp.read() == FAKE_PNG + assert aioclient_mock.call_count == 1 + + +async def test_integration_view_falls_back_to_brand( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that a brand-only domain is served from the brand namespace.""" + aioclient_mock.get( + f"{BRANDS_CDN_URL}/apple/icon.png", + status=HTTPStatus.NOT_FOUND, + ) + aioclient_mock.get( + f"{BRANDS_CDN_URL}/brands/apple/icon.png", + content=BRAND_PNG, + ) + + client = await hass_client() + resp = await client.get("/api/brands/integration/apple/icon.png") + + assert resp.status == HTTPStatus.OK + assert await resp.read() == BRAND_PNG + assert aioclient_mock.call_count == 2 + + # The integration 404 marker and the brand image are cached separately + resp = await client.get("/api/brands/integration/apple/icon.png") + assert resp.status == HTTPStatus.OK + assert await resp.read() == BRAND_PNG + assert aioclient_mock.call_count == 2 + + async def test_integration_view_default_placeholder_fallback( hass: HomeAssistant, hass_client: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, ) -> None: """Test that CDN 404 serves placeholder by default.""" + aioclient_mock.get( + f"{BRANDS_CDN_URL}/nonexistent/icon.png", + status=HTTPStatus.NOT_FOUND, + ) aioclient_mock.get( f"{BRANDS_CDN_URL}/brands/nonexistent/icon.png", status=HTTPStatus.NOT_FOUND, @@ -113,6 +170,10 @@ async def test_integration_view_no_placeholder( aioclient_mock: AiohttpClientMocker, ) -> None: """Test that CDN 404 returns 404 when placeholder=no is set.""" + aioclient_mock.get( + f"{BRANDS_CDN_URL}/nonexistent/icon.png", + status=HTTPStatus.NOT_FOUND, + ) aioclient_mock.get( f"{BRANDS_CDN_URL}/brands/nonexistent/icon.png", status=HTTPStatus.NOT_FOUND, @@ -187,7 +248,7 @@ async def test_integration_view_all_allowed_images( ] for image in allowed: aioclient_mock.get( - f"{BRANDS_CDN_URL}/brands/hue/{image}", + f"{BRANDS_CDN_URL}/hue/{image}", content=FAKE_PNG, ) @@ -203,6 +264,10 @@ async def test_integration_view_cdn_error_returns_none( aioclient_mock: AiohttpClientMocker, ) -> None: """Test that CDN connection errors result in 404 with placeholder=no.""" + aioclient_mock.get( + f"{BRANDS_CDN_URL}/broken/icon.png", + exc=ClientError(), + ) aioclient_mock.get( f"{BRANDS_CDN_URL}/brands/broken/icon.png", exc=ClientError(), @@ -220,6 +285,10 @@ async def test_integration_view_cdn_unexpected_status( aioclient_mock: AiohttpClientMocker, ) -> None: """Test that unexpected CDN status codes result in 404 with placeholder=no.""" + aioclient_mock.get( + f"{BRANDS_CDN_URL}/broken/icon.png", + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) aioclient_mock.get( f"{BRANDS_CDN_URL}/brands/broken/icon.png", status=HTTPStatus.INTERNAL_SERVER_ERROR, @@ -243,7 +312,7 @@ async def test_disk_cache_hit( ) -> None: """Test that a second request is served from disk cache.""" aioclient_mock.get( - f"{BRANDS_CDN_URL}/brands/hue/icon.png", + f"{BRANDS_CDN_URL}/hue/icon.png", content=FAKE_PNG, ) @@ -267,6 +336,10 @@ async def test_disk_cache_404_marker( aioclient_mock: AiohttpClientMocker, ) -> None: """Test that 404s are cached as empty files.""" + aioclient_mock.get( + f"{BRANDS_CDN_URL}/nothing/icon.png", + status=HTTPStatus.NOT_FOUND, + ) aioclient_mock.get( f"{BRANDS_CDN_URL}/brands/nothing/icon.png", status=HTTPStatus.NOT_FOUND, @@ -274,15 +347,16 @@ async def test_disk_cache_404_marker( client = await hass_client() - # First request: CDN returns 404, cached as empty file + # First request: CDN returns 404 for both namespaces, each cached as an + # empty file resp = await client.get("/api/brands/integration/nothing/icon.png?placeholder=no") assert resp.status == HTTPStatus.NOT_FOUND - assert aioclient_mock.call_count == 1 + assert aioclient_mock.call_count == 2 - # Second request: served from cached 404 marker + # Second request: served from cached 404 markers resp = await client.get("/api/brands/integration/nothing/icon.png?placeholder=no") assert resp.status == HTTPStatus.NOT_FOUND - assert aioclient_mock.call_count == 1 # No additional CDN call + assert aioclient_mock.call_count == 2 # No additional CDN call async def test_stale_cache_triggers_background_refresh( @@ -292,7 +366,7 @@ async def test_stale_cache_triggers_background_refresh( ) -> None: """Test that stale cache entries trigger background refresh.""" aioclient_mock.get( - f"{BRANDS_CDN_URL}/brands/hue/icon.png", + f"{BRANDS_CDN_URL}/hue/icon.png", content=FAKE_PNG, ) @@ -330,6 +404,10 @@ async def test_stale_cache_404_marker_with_placeholder( aioclient_mock: AiohttpClientMocker, ) -> None: """Test that stale cached 404 serves placeholder by default.""" + aioclient_mock.get( + f"{BRANDS_CDN_URL}/gone/icon.png", + status=HTTPStatus.NOT_FOUND, + ) aioclient_mock.get( f"{BRANDS_CDN_URL}/brands/gone/icon.png", status=HTTPStatus.NOT_FOUND, @@ -344,7 +422,7 @@ async def test_stale_cache_404_marker_with_placeholder( # First request caches the 404 (with placeholder=no) resp = await client.get("/api/brands/integration/gone/icon.png?placeholder=no") assert resp.status == HTTPStatus.NOT_FOUND - assert aioclient_mock.call_count == 1 + assert aioclient_mock.call_count == 2 # Make the cache stale cache_path = ( @@ -366,6 +444,10 @@ async def test_stale_cache_404_marker_no_placeholder( aioclient_mock: AiohttpClientMocker, ) -> None: """Test that stale cached 404 with placeholder=no returns 404.""" + aioclient_mock.get( + f"{BRANDS_CDN_URL}/gone/icon.png", + status=HTTPStatus.NOT_FOUND, + ) aioclient_mock.get( f"{BRANDS_CDN_URL}/brands/gone/icon.png", status=HTTPStatus.NOT_FOUND, @@ -376,7 +458,7 @@ async def test_stale_cache_404_marker_no_placeholder( # First request caches the 404 resp = await client.get("/api/brands/integration/gone/icon.png?placeholder=no") assert resp.status == HTTPStatus.NOT_FOUND - assert aioclient_mock.call_count == 1 + assert aioclient_mock.call_count == 2 # Make the cache stale cache_path = ( @@ -390,9 +472,9 @@ async def test_stale_cache_404_marker_no_placeholder( resp = await client.get("/api/brands/integration/gone/icon.png?placeholder=no") assert resp.status == HTTPStatus.NOT_FOUND - # Background refresh should have been triggered + # Background refresh should have been triggered for the stale marker await hass.async_block_till_done() - assert aioclient_mock.call_count == 2 + assert aioclient_mock.call_count == 3 # ------------------------------------------------------------------ @@ -435,7 +517,7 @@ async def test_custom_integration_no_brand_falls_through( custom = _create_custom_integration(hass, "my_custom", has_branding=False) aioclient_mock.get( - f"{BRANDS_CDN_URL}/brands/my_custom/icon.png", + f"{BRANDS_CDN_URL}/my_custom/icon.png", content=FAKE_PNG, ) @@ -463,7 +545,7 @@ async def test_custom_integration_brand_missing_file_falls_through( brand_dir.mkdir(parents=True, exist_ok=True) aioclient_mock.get( - f"{BRANDS_CDN_URL}/brands/my_custom/icon.png", + f"{BRANDS_CDN_URL}/my_custom/icon.png", content=FAKE_PNG, ) @@ -488,7 +570,7 @@ async def test_custom_integration_takes_priority_over_cache( # Prime the CDN cache first aioclient_mock.get( - f"{BRANDS_CDN_URL}/brands/my_custom/icon.png", + f"{BRANDS_CDN_URL}/my_custom/icon.png", content=FAKE_PNG, ) @@ -672,7 +754,7 @@ async def test_custom_integration_no_fallback_match_falls_through_to_cdn( # brand dir exists but is empty - no icon.png either aioclient_mock.get( - f"{BRANDS_CDN_URL}/brands/my_custom/icon.png", + f"{BRANDS_CDN_URL}/my_custom/icon.png", content=FAKE_PNG, ) @@ -757,6 +839,10 @@ async def test_cdn_timeout_returns_404( aioclient_mock: AiohttpClientMocker, ) -> None: """Test that CDN timeout results in 404 with placeholder=no.""" + aioclient_mock.get( + f"{BRANDS_CDN_URL}/slow/icon.png", + exc=TimeoutError(), + ) aioclient_mock.get( f"{BRANDS_CDN_URL}/brands/slow/icon.png", exc=TimeoutError(), @@ -780,7 +866,7 @@ async def test_authenticated_request( ) -> None: """Test that authenticated requests succeed.""" aioclient_mock.get( - f"{BRANDS_CDN_URL}/brands/hue/icon.png", + f"{BRANDS_CDN_URL}/hue/icon.png", content=FAKE_PNG, ) @@ -797,7 +883,7 @@ async def test_token_query_param_authentication( ) -> None: """Test that a valid access token in query param authenticates.""" aioclient_mock.get( - f"{BRANDS_CDN_URL}/brands/hue/icon.png", + f"{BRANDS_CDN_URL}/hue/icon.png", content=FAKE_PNG, ) @@ -857,7 +943,7 @@ async def test_token_rotation( ) -> None: """Test that access tokens rotate over time.""" aioclient_mock.get( - f"{BRANDS_CDN_URL}/brands/hue/icon.png", + f"{BRANDS_CDN_URL}/hue/icon.png", content=FAKE_PNG, ) From 2809e5fdf203288491f0711b21bd2555d02d426a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:53:33 +0200 Subject: [PATCH 37/43] Bump anyio from 4.13.0 to 4.14.2 (#182665) Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> Signed-off-by: dependabot[bot] --- homeassistant/components/mcp_server/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- script/gen_requirements_all.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mcp_server/manifest.json b/homeassistant/components/mcp_server/manifest.json index a70914369e0431..df019aef4dc878 100644 --- a/homeassistant/components/mcp_server/manifest.json +++ b/homeassistant/components/mcp_server/manifest.json @@ -8,6 +8,6 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["mcp==1.28.1", "aiohttp_sse==2.2.0", "anyio==4.13.0"], + "requirements": ["mcp==1.28.1", "aiohttp_sse==2.2.0", "anyio==4.14.2"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 691ca079b1a219..5c739ba5f33eae 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -121,7 +121,7 @@ uuid==1000000000.0.0 # even newer versions seem to introduce new issues, it's useful # for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.13.0 +anyio==4.14.2 h11==0.16.0 httpcore==1.0.9 diff --git a/requirements_all.txt b/requirements_all.txt index 3c32b98e0ca09e..6fca6a30171d50 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -558,7 +558,7 @@ anthemav==1.4.2 anthropic==0.108.0 # homeassistant.components.mcp_server -anyio==4.13.0 +anyio==4.14.2 # homeassistant.components.weatherkit apple_weatherkit==1.1.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index ea12c30d2ddb55..4782ecea897d5a 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -105,7 +105,7 @@ # even newer versions seem to introduce new issues, it's useful # for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.13.0 +anyio==4.14.2 h11==0.16.0 httpcore==1.0.9 From a72ca7b2d755d3d4ed6f2378b05ad34878adce09 Mon Sep 17 00:00:00 2001 From: jdavidbush Date: Sat, 19 Sep 2026 02:57:05 -0600 Subject: [PATCH 38/43] Add brightness_pct beside brightness in the LLM live context (#182571) --- homeassistant/components/homeassistant/llm.py | 9 ++++ tests/components/homeassistant/test_llm.py | 42 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/homeassistant/components/homeassistant/llm.py b/homeassistant/components/homeassistant/llm.py index e533580f30714d..9272f2d79bd498 100644 --- a/homeassistant/components/homeassistant/llm.py +++ b/homeassistant/components/homeassistant/llm.py @@ -7,6 +7,7 @@ import probatio +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.llm import LLMTools from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, @@ -172,6 +173,14 @@ def async_get_exposed_entities( if attr_name in interesting_attributes } ): + # Tools take brightness as a 0-100 percentage; the attribute is 0-255. + if state.domain == LIGHT_DOMAIN and isinstance( + brightness := state.attributes.get("brightness"), int + ): + pct = round(brightness / 255 * 100) + attributes["brightness_pct"] = str( + max(pct, 1) if brightness > 0 else pct + ) info["attributes"] = attributes entities[state.entity_id] = info diff --git a/tests/components/homeassistant/test_llm.py b/tests/components/homeassistant/test_llm.py index a568d79d34d579..0d7fd73ed74fa2 100644 --- a/tests/components/homeassistant/test_llm.py +++ b/tests/components/homeassistant/test_llm.py @@ -412,3 +412,45 @@ async def test_get_live_context_schema( schema = to_openapi(tool.parameters, custom_serializer=api.custom_serializer) assert schema == snapshot + + +async def test_get_exposed_entities_brightness_percentage(hass: HomeAssistant) -> None: + """Test that a light's brightness is also rendered as a percentage.""" + hass.states.async_set( + ENTITY_ID, "on", {"friendly_name": "Kitchen Light", "brightness": 128} + ) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + + exposed = async_get_exposed_entities(hass, "conversation", include_state=True) + attributes = exposed[ENTITY_ID]["attributes"] + + # The raw attribute is unchanged, so anything reading it keeps working. + assert attributes["brightness"] == "128" + # The percentage is the inverse of the percentage-to-brightness conversion. + assert attributes["brightness_pct"] == "50" + + hass.states.async_set( + ENTITY_ID, "on", {"friendly_name": "Kitchen Light", "brightness": 255} + ) + exposed = async_get_exposed_entities(hass, "conversation", include_state=True) + assert exposed[ENTITY_ID]["attributes"]["brightness_pct"] == "100" + + # A lit light never rounds down to nothing. + hass.states.async_set( + ENTITY_ID, "on", {"friendly_name": "Kitchen Light", "brightness": 1} + ) + exposed = async_get_exposed_entities(hass, "conversation", include_state=True) + assert exposed[ENTITY_ID]["attributes"]["brightness_pct"] == "1" + + hass.states.async_set(ENTITY_ID, "off", {"friendly_name": "Kitchen Light"}) + exposed = async_get_exposed_entities(hass, "conversation", include_state=True) + assert "brightness_pct" not in exposed[ENTITY_ID].get("attributes", {}) + + # Only lights get the percentage, whatever attribute another domain carries. + hass.states.async_set( + "fan.kitchen", "on", {"friendly_name": "Kitchen Fan", "brightness": 128} + ) + async_expose_entity(hass, "conversation", "fan.kitchen", True) + exposed = async_get_exposed_entities(hass, "conversation", include_state=True) + assert exposed["fan.kitchen"]["attributes"]["brightness"] == "128" + assert "brightness_pct" not in exposed["fan.kitchen"]["attributes"] From c85031d9eacb72b138c5867dcadf27e6f8d022c0 Mon Sep 17 00:00:00 2001 From: Abdellatif Anaflous <62770500+hktitof@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:00:49 +0100 Subject: [PATCH 39/43] Expose listed emulated_hue entities when expose_by_default is off (#182129) --- .../components/emulated_hue/config.py | 4 +- tests/components/emulated_hue/test_hue_api.py | 49 ++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/emulated_hue/config.py b/homeassistant/components/emulated_hue/config.py index c5cd4a6e76c38e..888e527fc81a94 100644 --- a/homeassistant/components/emulated_hue/config.py +++ b/homeassistant/components/emulated_hue/config.py @@ -253,7 +253,9 @@ def _is_state_exposed(self, state: State) -> bool: return not self._entities_with_hidden_attr_in_config[state.entity_id] if not self.expose_by_default: - return False + # Listing an entity is the documented way to expose just a few + # devices when expose_by_default is off + return state.entity_id in self.entities # Expose an entity if the entity's domain is exposed by default and # the configuration doesn't explicitly exclude it from being # exposed, or if the entity is explicitly exposed diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index dfc0000c21251a..370383c2e4b01e 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -1769,6 +1769,36 @@ async def test_only_change_hue_or_saturation( ] == (0, 3) +@pytest.mark.usefixtures("base_setup") +@pytest.mark.parametrize( + ("entity_config", "expected_exposed"), + [ + pytest.param({}, True, id="hidden_omitted"), + pytest.param({emulated_hue.CONF_ENTITY_HIDDEN: False}, True, id="hidden_false"), + pytest.param({emulated_hue.CONF_ENTITY_HIDDEN: True}, False, id="hidden_true"), + ], +) +async def test_listed_entity_exposed_with_expose_by_default_off( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + entity_config: dict[str, bool], + expected_exposed: bool, +) -> None: + """Test an explicitly listed entity is exposed with expose by default off.""" + conf = { + emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT, + emulated_hue.CONF_EXPOSE_BY_DEFAULT: False, + emulated_hue.CONF_ENTITIES: {"light.exposed": entity_config}, + } + await _async_setup_emulated_hue(hass, conf) + _mock_hue_endpoints(hass, conf, {"1": "light.exposed"}) + hass.states.async_set("light.exposed", STATE_ON) + await hass.async_block_till_done() + client = await hass_client_no_auth() + result_json = await async_get_lights(client) + assert bool(result_json) is expected_exposed + + @pytest.mark.usefixtures("base_setup") async def test_specificly_exposed_entities( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator @@ -1797,10 +1827,27 @@ async def test_specificly_exposed_entities( hass.states.async_set("light.exposed", STATE_ON) await hass.async_block_till_done() result_json = await async_get_lights(client) - assert "1" in result_json +@pytest.mark.usefixtures("base_setup") +async def test_unlisted_entity_not_exposed_with_expose_by_default_off( + hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator +) -> None: + """Test an entity not listed in the config is not exposed by default off.""" + conf = { + emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT, + emulated_hue.CONF_EXPOSE_BY_DEFAULT: False, + } + await _async_setup_emulated_hue(hass, conf) + _mock_hue_endpoints(hass, conf, {"1": "light.unlisted"}) + hass.states.async_set("light.unlisted", STATE_ON) + await hass.async_block_till_done() + client = await hass_client_no_auth() + result_json = await async_get_lights(client) + assert not result_json + + async def test_get_light_state_when_none( hass_hue: HomeAssistant, hue_client: TestClient ) -> None: From af4a191d6ed7109215d0dd95e31cc58ab2f711a2 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sat, 19 Sep 2026 11:02:04 +0200 Subject: [PATCH 40/43] Add MPPT entities for Fronius inverters with more than 4 modules (#182305) Co-authored-by: Claude Opus 5 --- homeassistant/components/fronius/sensor.py | 5 ++- tests/components/fronius/test_modbus.py | 48 ++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index ad3831f3691427..1d096cd13fbb03 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -336,10 +336,11 @@ def _modbus_mppt_descriptions( MODBUS_INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ - # SunSpec model 160 supports up to 4 MPPT modules (GEN24 hybrid, Tauro) + # Verto Plus exposes 5 modules (3 PV trackers plus storage charge/discharge), + # one more leaves headroom for a hybrid with 4 PV trackers *( description - for mppt_no in range(1, 5) + for mppt_no in range(1, 7) for description in _modbus_mppt_descriptions(mppt_no) ), FroniusSensorEntityDescription( diff --git a/tests/components/fronius/test_modbus.py b/tests/components/fronius/test_modbus.py index 9806d210128190..ae1bca2f58e9ba 100644 --- a/tests/components/fronius/test_modbus.py +++ b/tests/components/fronius/test_modbus.py @@ -92,6 +92,54 @@ async def test_gen24_storage_mppt( assert len(solar_net.modbus_inverter_coordinators) == 1 +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_verto_plus_five_modules( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_fronius_modbus: MockModbusConnection, +) -> None: + """Test a Verto Plus exposing 3 PV trackers plus storage charge/discharge.""" + mock_fronius_modbus.for_unit(1).holding.update( + build_sunspec_map( + [ + MpptModuleSpec( + id_str="MPPT 1", current=82, voltage=4021, power=3300, energy=1_000 + ), + MpptModuleSpec( + id_str="MPPT 2", current=41, voltage=4022, power=1650, energy=2_000 + ), + MpptModuleSpec( + id_str="MPPT 3", current=20, voltage=4023, power=800, energy=3_000 + ), + MpptModuleSpec( + id_str="StCha 4", current=0, voltage=0, power=0, energy=4_000 + ), + MpptModuleSpec( + id_str="StDisCha 5", + current=12, + voltage=3990, + power=480, + energy=5_000, + ), + ], + storage_wcha_max=5000, + ) + ) + mock_responses(aioclient_mock, fixture_set="gen24_storage") + with patch("homeassistant.components.fronius.PLATFORMS", [Platform.SENSOR]): + await setup_fronius_integration(hass, is_logger=False, unique_id="12345678") + + assert_state(hass, "sensor.gen24_storage_mppt_3_dc_power", 800) + assert_state(hass, "sensor.gen24_storage_mppt_4_dc_power", 0) + assert_state(hass, "sensor.gen24_storage_mppt_5_dc_current", 1.2) + assert_state(hass, "sensor.gen24_storage_mppt_5_dc_voltage", 399.0) + assert_state(hass, "sensor.gen24_storage_mppt_5_dc_power", 480) + assert_state(hass, "sensor.gen24_storage_mppt_5_energy", 5000) + assert_state(hass, "sensor.gen24_storage_pv_energy_total", 6000) + assert_state(hass, "sensor.gen24_storage_battery_charging_energy_total", 4000) + assert_state(hass, "sensor.gen24_storage_battery_discharging_energy_total", 5000) + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("storage_id_str", "expected_pv_energy_total"), From 31994541852b061cc4873f2dd0bb67fd1af06cba Mon Sep 17 00:00:00 2001 From: Jonas Petersson Date: Sat, 19 Sep 2026 11:03:02 +0200 Subject: [PATCH 41/43] Use glass break device class in Hive (#182239) Co-authored-by: Codex --- .../components/hive/binary_sensor.py | 2 +- tests/components/hive/test_init.py | 53 ++++++++++++++++++- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hive/binary_sensor.py b/homeassistant/components/hive/binary_sensor.py index fc931bebff0582..3393924ba4f18f 100644 --- a/homeassistant/components/hive/binary_sensor.py +++ b/homeassistant/components/hive/binary_sensor.py @@ -42,7 +42,7 @@ ), BinarySensorEntityDescription( key="GLASS_BREAK", - device_class=BinarySensorDeviceClass.SOUND, + device_class=BinarySensorDeviceClass.GLASS_BREAK, ), ) diff --git a/tests/components/hive/test_init.py b/tests/components/hive/test_init.py index e33a554f25b827..936052be72179b 100644 --- a/tests/components/hive/test_init.py +++ b/tests/components/hive/test_init.py @@ -3,10 +3,11 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.hive.const import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ATTR_DEVICE_CLASS, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -50,6 +51,23 @@ "status": {"state": True}, } +_GLASS_BREAK_BINARY_SENSOR = { + "device_id": "hive-glass-break-id", + "hiveID": "hive-glass-break-id", + "hiveName": "Glass Break", + "haName": "Glass Break", + "device_name": "Glass Break Sensor", + "hiveType": "GLASS_BREAK", + "parentDevice": "hive-hub-id", + "deviceData": { + "model": "Glass Break Sensor", + "version": "1.2.3", + "manufacturer": "Hive", + "online": True, + }, + "status": {"state": False}, +} + # The hub's own diagnostic sensor reports the hub as its own parent # (parentDevice == device_id), which would link the hub device to itself. _HUB_BINARY_SENSOR = { @@ -195,3 +213,34 @@ async def test_hub_diagnostic_sensor_not_linked_to_itself( ) assert hub_device is not None assert hub_device.via_device_id is None + + +async def test_glass_break_device_class( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test the glass break binary sensor device class.""" + entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA) + entry.add_to_hass(hass) + + mock_hive = _make_mock_hive( + {"macAddress": "00:1C:2B:1C:2E:68"}, + {"binary_sensor": [_GLASS_BREAK_BINARY_SENSOR], "sensor": []}, + ) + mock_hive.session.updateData = AsyncMock() + mock_hive.sensor.getSensor = AsyncMock(side_effect=lambda device: device) + + with patch( + "homeassistant.components.hive.Hive", + return_value=mock_hive, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = entity_registry.async_get_entity_id( + "binary_sensor", DOMAIN, "hive-glass-break-id-GLASS_BREAK" + ) + assert entity_id + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.GLASS_BREAK From fee52805abcd8a2b57ec4f7791e724a371508f47 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 19 Sep 2026 11:24:03 +0200 Subject: [PATCH 42/43] Handle unavailable UniFi configuration endpoint (#182574) --- homeassistant/components/unifi/coordinator.py | 24 +++++++++- .../components/unifi/hub/entity_loader.py | 4 +- tests/components/unifi/test_hub.py | 44 +++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi/coordinator.py b/homeassistant/components/unifi/coordinator.py index cca4293d756615..daee12d4f157dd 100644 --- a/homeassistant/components/unifi/coordinator.py +++ b/homeassistant/components/unifi/coordinator.py @@ -3,10 +3,11 @@ from datetime import timedelta from typing import TYPE_CHECKING, override +from aiounifi import EndpointNotFound from aiounifi.interfaces.api_handlers import APIHandler, ItemEvent from homeassistant.core import callback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import LOGGER @@ -25,6 +26,8 @@ def __init__( self, hub: UnifiHub, handler: HandlerT, + *, + disable_polling_on_endpoint_not_found: bool = False, ) -> None: """Initialize coordinator.""" supports_websocket = bool(handler.process_messages or handler.remove_messages) @@ -36,6 +39,10 @@ def __init__( update_interval=None if supports_websocket else POLL_INTERVAL, ) self._handler = handler + self._disable_polling_on_endpoint_not_found = ( + disable_polling_on_endpoint_not_found + ) + self._endpoint_not_found_logged = False hub.config.entry.async_on_unload(handler.subscribe(self._async_handle_update)) @@ -47,7 +54,20 @@ def handler(self) -> HandlerT: @override async def _async_update_data(self) -> None: """Update data from the API handler.""" - await self._handler.update() + try: + await self._handler.update() + except EndpointNotFound as err: + if ( + self._disable_polling_on_endpoint_not_found + and not self._endpoint_not_found_logged + ): + self._endpoint_not_found_logged = True + self.update_interval = None + self.logger.warning( + "UniFi %s endpoint is unavailable; disabling polling", + type(self._handler).__name__, + ) + raise UpdateFailed(str(err)) from err @callback def _async_handle_update(self, event: ItemEvent, obj_id: str) -> None: diff --git a/homeassistant/components/unifi/hub/entity_loader.py b/homeassistant/components/unifi/hub/entity_loader.py index e38db332d65960..462df0f86d13e3 100644 --- a/homeassistant/components/unifi/hub/entity_loader.py +++ b/homeassistant/components/unifi/hub/entity_loader.py @@ -53,7 +53,9 @@ def __init__(self, hub: UnifiHub) -> None: hub, hub.api.firewall_policies ), id(hub.api.object_oriented_network_configs): UnifiDataUpdateCoordinator( - hub, hub.api.object_oriented_network_configs + hub, + hub.api.object_oriented_network_configs, + disable_polling_on_endpoint_not_found=True, ), id(hub.api.port_forwarding): UnifiDataUpdateCoordinator( hub, hub.api.port_forwarding diff --git a/tests/components/unifi/test_hub.py b/tests/components/unifi/test_hub.py index ab3821fdb8512a..d1242cf03870a1 100644 --- a/tests/components/unifi/test_hub.py +++ b/tests/components/unifi/test_hub.py @@ -6,6 +6,7 @@ from unittest.mock import patch import aiounifi +from aiounifi import EndpointNotFound from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.models.message import MessageKey import pytest @@ -122,6 +123,49 @@ async def test_polling_coordinator_refreshes_after_interval( assert mock_update.call_count >= 1 +async def test_endpoint_not_found_disables_object_oriented_network_config_polling( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + config_entry_setup: MockConfigEntry, +) -> None: + """Ensure an unavailable optional endpoint stops polling after one warning.""" + loader = config_entry_setup.runtime_data.entity_loader + api = config_entry_setup.runtime_data.api + coordinator = loader.get_data_update_coordinator( + api.object_oriented_network_configs + ) + traffic_rules_coordinator = loader.get_data_update_coordinator(api.traffic_rules) + + with patch.object( + coordinator.handler, + "update", + side_effect=EndpointNotFound("endpoint not found"), + ) as mock_update: + await coordinator.async_refresh() + await hass.async_block_till_done() + + assert coordinator.update_interval is None + assert coordinator.last_update_success is False + assert mock_update.call_count == 1 + + async_fire_time_changed(hass, dt_util.utcnow() + POLL_INTERVAL) + await hass.async_block_till_done() + + assert mock_update.call_count == 1 + + await coordinator.async_refresh() + await hass.async_block_till_done() + + assert mock_update.call_count == 2 + assert traffic_rules_coordinator.update_interval == POLL_INTERVAL + assert ( + caplog.text.count( + "UniFi ObjectOrientedNetworkConfigs endpoint is unavailable; disabling polling" + ) + == 1 + ) + + @pytest.mark.parametrize( "object_oriented_network_config_payload", [ From 2329db5a05085ef6d7b8e6b00eb380a9a4ad4091 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Sat, 19 Sep 2026 11:25:53 +0200 Subject: [PATCH 43/43] Track devolo Home Network Wi-Fi clients via the entity registry (#182327) --- .../devolo_home_network/device_tracker.py | 49 ++++++------------- .../test_device_tracker.py | 25 ++++++++++ 2 files changed, 41 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index 185e01a09432d2..4595ad83ee24da 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -32,47 +32,30 @@ async def async_setup_entry( str, DevoloDataUpdateCoordinator[dict[str, ConnectedStationInfo]] ] = entry.runtime_data.coordinators registry = er.async_get(hass) - tracked = set() @callback def new_device_callback() -> None: - """Add new devices if needed.""" - new_entities = [] - for mac_address in coordinators[CONNECTED_WIFI_CLIENTS].data: - if mac_address in tracked: - continue - - new_entities.append( - DevoloScannerEntity( - coordinators[CONNECTED_WIFI_CLIENTS], device, mac_address - ) + """Add clients that don't have an entity yet.""" + async_add_entities( + DevoloScannerEntity(coordinators[CONNECTED_WIFI_CLIENTS], device, mac) + for mac in coordinators[CONNECTED_WIFI_CLIENTS].data + if not registry.async_get_entity_id( + DEVICE_TRACKER_DOMAIN, DOMAIN, f"{device.serial_number}_{mac}" ) - tracked.add(mac_address) - async_add_entities(new_entities) + ) @callback def restore_entities() -> None: """Restore clients that are not a part of active clients list.""" - missing = [] - for entity in er.async_entries_for_config_entry(registry, entry.entry_id): - if ( - entity.platform == DOMAIN - and entity.domain == DEVICE_TRACKER_DOMAIN - and ( - mac_address := entity.unique_id.replace( - f"{device.serial_number}_", "" - ) - ) - not in tracked - ): - missing.append( - DevoloScannerEntity( - coordinators[CONNECTED_WIFI_CLIENTS], device, mac_address - ) - ) - tracked.add(mac_address) - - async_add_entities(missing) + async_add_entities( + DevoloScannerEntity( + coordinators[CONNECTED_WIFI_CLIENTS], + device, + entity.unique_id.removeprefix(f"{device.serial_number}_"), + ) + for entity in er.async_entries_for_config_entry(registry, entry.entry_id) + if entity.platform == DOMAIN and entity.domain == DEVICE_TRACKER_DOMAIN + ) restore_entities() new_device_callback() diff --git a/tests/components/devolo_home_network/test_device_tracker.py b/tests/components/devolo_home_network/test_device_tracker.py index 86ce0122c2e6b3..d278c471483810 100644 --- a/tests/components/devolo_home_network/test_device_tracker.py +++ b/tests/components/devolo_home_network/test_device_tracker.py @@ -95,3 +95,28 @@ async def test_restoring_clients( state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_NOT_HOME + + +@pytest.mark.usefixtures("mock_device", "entity_registry_enabled_by_default") +async def test_recreating_removed_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that the entity registry is the source of truth for tracked clients.""" + entity_id = ( + f"{DEVICE_TRACKER_DOMAIN}.{STATION.mac_address.lower().replace(':', '_')}" + ) + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entity_registry.async_get(entity_id) is not None + + entity_registry.async_remove(entity_id) + await hass.async_block_till_done() + assert entity_registry.async_get(entity_id) is None + + freezer.tick(SHORT_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert entity_registry.async_get(entity_id) is not None