From 7c519ddacad6743c54f5f17d1943a76e8408dd45 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 19 Aug 2026 08:02:17 +0300 Subject: [PATCH 1/8] Add Matter network topology WebSocket API (#177114) --- homeassistant/components/matter/api.py | 140 +++++++++++- homeassistant/components/matter/helpers.py | 12 + tests/components/matter/test_api.py | 246 ++++++++++++++++++++- 3 files changed, 395 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/api.py b/homeassistant/components/matter/api.py index fce05ca6c37e3..5e3221d6829b8 100644 --- a/homeassistant/components/matter/api.py +++ b/homeassistant/components/matter/api.py @@ -4,17 +4,25 @@ from functools import wraps from typing import Any, Concatenate +from matter_server.client.exceptions import ServerVersionTooOld from matter_server.client.models.node import MatterNode from matter_server.common.errors import MatterError from matter_server.common.helpers.util import dataclass_to_dict +from matter_server.common.models import EventType, NetworkTopology import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.components.websocket_api import ActiveConnection +from homeassistant.components.websocket_api import ERR_NOT_SUPPORTED, ActiveConnection from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from .adapter import MatterAdapter -from .helpers import MissingNode, get_matter, node_from_ha_device_id +from .helpers import ( + MissingNode, + get_matter, + get_node_device_identifier, + node_from_ha_device_id, +) ID = "id" TYPE = "type" @@ -23,6 +31,9 @@ ERROR_NODE_NOT_FOUND = "node_not_found" +# minimum server schema version that provides network topology +TOPOLOGY_SCHEMA_VERSION = 13 + @callback def async_register_api(hass: HomeAssistant) -> None: @@ -36,6 +47,8 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_open_commissioning_window) websocket_api.async_register_command(hass, websocket_remove_matter_fabric) websocket_api.async_register_command(hass, websocket_interview_node) + websocket_api.async_register_command(hass, websocket_network_topology) + websocket_api.async_register_command(hass, websocket_subscribe_network_topology) def async_get_node( @@ -115,6 +128,8 @@ async def async_handle_failed_command_func( connection.send_error(msg[ID], str(err.error_code), err.args[0]) except MissingNode as err: connection.send_error(msg[ID], ERROR_NODE_NOT_FOUND, err.args[0]) + except ServerVersionTooOld as err: + connection.send_error(msg[ID], ERR_NOT_SUPPORTED, err.args[0]) return async_handle_failed_command_func @@ -328,3 +343,124 @@ async def websocket_interview_node( """Interview a node.""" await matter.matter_client.interview_node(node_id=node.node_id) connection.send_result(msg[ID]) + + +@callback +def _topology_supported( + connection: ActiveConnection, msg: dict[str, Any], matter: MatterAdapter +) -> bool: + """Check if the server supports network topology, send an error if not.""" + server_info = matter.matter_client.server_info + if server_info is None or server_info.schema_version < TOPOLOGY_SCHEMA_VERSION: + connection.send_error( + msg[ID], + ERR_NOT_SUPPORTED, + "The Matter server does not support network topology " + f"(requires schema version {TOPOLOGY_SCHEMA_VERSION}).", + ) + return False + return True + + +@callback +def _serialize_topology( + hass: HomeAssistant, matter: MatterAdapter, topology: NetworkTopology +) -> dict[str, Any]: + """Serialize a topology snapshot, annotating nodes with HA device ids.""" + server_info = matter.matter_client.server_info + dev_reg = dr.async_get(hass) + result: dict[str, Any] = dataclass_to_dict(topology) + for node in result["nodes"]: + device = None + if (node_id := node.get("node_id")) is not None and server_info is not None: + device = dev_reg.async_get_device_by_identifier( + get_node_device_identifier(server_info, node_id), + matter.config_entry.entry_id, + ) + node["ha_device_id"] = device.id if device else None + return result + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "matter/network_topology", + vol.Optional("refresh", default=False): bool, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_matter_adapter +async def websocket_network_topology( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + matter: MatterAdapter, +) -> None: + """Get the network topology graph.""" + if not _topology_supported(connection, msg, matter): + return + topology = await matter.matter_client.get_network_topology(refresh=msg["refresh"]) + connection.send_result(msg[ID], _serialize_topology(hass, matter, topology)) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "matter/subscribe_network_topology", + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_matter_adapter +async def websocket_subscribe_network_topology( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + matter: MatterAdapter, +) -> None: + """Subscribe to network topology updates.""" + if not _topology_supported(connection, msg, matter): + return + + initial_sent = False + # updates are full snapshots, so only the newest buffered one matters + buffered: NetworkTopology | None = None + + @callback + def forward_topology(event: EventType, topology: NetworkTopology) -> None: + nonlocal buffered + if not initial_sent: + buffered = topology + return + connection.send_message( + websocket_api.event_message( + msg[ID], _serialize_topology(hass, matter, topology) + ) + ) + + # subscribe before the fetch: the fetch opts this client in server-side, + # and an update may arrive before the command result does + unsubscribe = matter.matter_client.subscribe_events( + callback=forward_topology, + event_filter=EventType.NETWORK_TOPOLOGY_UPDATED, + ) + try: + topology = await matter.matter_client.get_network_topology() + except Exception: + unsubscribe() + raise + connection.subscriptions[msg[ID]] = unsubscribe + connection.send_result(msg[ID]) + connection.send_message( + websocket_api.event_message( + msg[ID], _serialize_topology(hass, matter, topology) + ) + ) + if buffered is not None: + connection.send_message( + websocket_api.event_message( + msg[ID], _serialize_topology(hass, matter, buffered) + ) + ) + initial_sent = True diff --git a/homeassistant/components/matter/helpers.py b/homeassistant/components/matter/helpers.py index fe8b305a90756..4867b894a6c16 100644 --- a/homeassistant/components/matter/helpers.py +++ b/homeassistant/components/matter/helpers.py @@ -83,6 +83,18 @@ def get_device_id( return f"{operational_instance_id}-{postfix}" +def get_node_device_identifier( + server_info: ServerInfoMessage, node_id: int +) -> tuple[str, str]: + """Return the device registry identifier for the node-level device of a node.""" + fabric_id_hex = f"{server_info.compressed_fabric_id:016X}" + node_id_hex = f"{node_id:016X}" + return ( + DOMAIN, + f"{ID_TYPE_DEVICE_ID}_{fabric_id_hex}-{node_id_hex}-MatterNodeDevice", + ) + + @callback def node_from_ha_device_id(hass: HomeAssistant, ha_device_id: str) -> MatterNode | None: """Get node id from ha device id.""" diff --git a/tests/components/matter/test_api.py b/tests/components/matter/test_api.py index 31acfdd3be920..3425fede40f83 100644 --- a/tests/components/matter/test_api.py +++ b/tests/components/matter/test_api.py @@ -1,7 +1,9 @@ """Test the api module.""" +from collections.abc import Callable from unittest.mock import AsyncMock, MagicMock, call +from matter_server.client.exceptions import ServerVersionTooOld from matter_server.client.models.node import ( MatterFabricData, NetworkType, @@ -10,7 +12,14 @@ ) from matter_server.common.errors import InvalidCommand, NodeCommissionFailed from matter_server.common.helpers.util import dataclass_to_dict -from matter_server.common.models import CommissioningParameters +from matter_server.common.models import ( + CommissioningParameters, + EventType, + NetworkTopology, + NetworkTopologyConnection, + NetworkTopologyNode, + TopologyDirectionInfo, +) import pytest from homeassistant.components.matter.api import ( @@ -460,3 +469,238 @@ async def test_interview_node( assert not msg["success"] assert msg["error"]["code"] == ERROR_NODE_NOT_FOUND + + +def _mock_topology() -> NetworkTopology: + """Return a mock topology with a known node, an unknown node and a border router.""" + return NetworkTopology( + collected_at=1767888000000, + nodes=[ + NetworkTopologyNode( + id="30", + kind="matter", + network_type="thread", + node_id=30, + role="router", + available=True, + ), + NetworkTopologyNode( + id="99", + kind="matter", + network_type="thread", + node_id=99, + role="end_device", + available=True, + ), + NetworkTopologyNode( + id="br_1122AABBCC334455", + kind="border_router", + network_type="thread", + role="router", + ext_address="1122AABBCC334455", + vendor_name="Apple", + ), + ], + connections=[ + NetworkTopologyConnection( + source="30", + target="br_1122AABBCC334455", + network="thread", + strength="strong", + source_to_target=TopologyDirectionInfo(strength="strong", lqi=3), + ), + ], + ) + + +def _expected_topology( + topology: NetworkTopology, ha_device_ids: list[str | None] +) -> dict: + """Return the expected ws payload for the given topology.""" + expected = dataclass_to_dict(topology) + for node, ha_device_id in zip(expected["nodes"], ha_device_ids, strict=True): + node["ha_device_id"] = ha_device_id + return expected + + +@pytest.mark.usefixtures("matter_node") +@pytest.mark.parametrize("node_fixture", ["mock_onoff_light"]) +async def test_network_topology( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + matter_client: MagicMock, +) -> None: + """Test the network_topology command.""" + matter_client.server_info.schema_version = 13 + entry = device_registry.async_get_device_by_identifier( + (DOMAIN, "deviceid_00000000000004D2-000000000000001E-MatterNodeDevice"), + hass.config_entries.async_entries(DOMAIN)[0].entry_id, + ) + assert entry is not None + + topology = _mock_topology() + matter_client.get_network_topology = AsyncMock(return_value=topology) + + ws_client = await hass_ws_client(hass) + await ws_client.send_json({ID: 1, TYPE: "matter/network_topology"}) + msg = await ws_client.receive_json() + + assert msg["success"] + # node 30 maps to the registry device, node 99 and the border router do not + assert msg["result"] == _expected_topology(topology, [entry.id, None, None]) + matter_client.get_network_topology.assert_called_once_with(refresh=False) + + matter_client.get_network_topology.reset_mock() + await ws_client.send_json({ID: 2, TYPE: "matter/network_topology", "refresh": True}) + msg = await ws_client.receive_json() + + assert msg["success"] + matter_client.get_network_topology.assert_called_once_with(refresh=True) + + +@pytest.mark.parametrize( + "command", ["matter/network_topology", "matter/subscribe_network_topology"] +) +@pytest.mark.usefixtures("integration") +async def test_network_topology_not_supported( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + matter_client: MagicMock, + command: str, +) -> None: + """Test the topology commands against a server without topology support.""" + # the conftest default schema version (1) predates network topology + matter_client.get_network_topology = AsyncMock() + + ws_client = await hass_ws_client(hass) + await ws_client.send_json({ID: 1, TYPE: command}) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "not_supported" + matter_client.get_network_topology.assert_not_called() + + # a version mismatch raised by the client also maps to not_supported + matter_client.server_info.schema_version = 13 + matter_client.get_network_topology.side_effect = ServerVersionTooOld( + "Command not available due to too old server version" + ) + await ws_client.send_json({ID: 2, TYPE: command}) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "not_supported" + + +@pytest.mark.usefixtures("matter_node") +@pytest.mark.parametrize("node_fixture", ["mock_onoff_light"]) +async def test_subscribe_network_topology( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + matter_client: MagicMock, +) -> None: + """Test the subscribe_network_topology command.""" + matter_client.server_info.schema_version = 13 + entry = device_registry.async_get_device_by_identifier( + (DOMAIN, "deviceid_00000000000004D2-000000000000001E-MatterNodeDevice"), + hass.config_entries.async_entries(DOMAIN)[0].entry_id, + ) + assert entry is not None + + topology = _mock_topology() + + subscription_callback: Callable[[EventType, NetworkTopology], None] | None = None + unsubscribe = MagicMock() + + def capture_subscription( + callback: Callable[[EventType, NetworkTopology], None], + event_filter: EventType | None = None, + node_filter: int | None = None, + attr_path_filter: str | None = None, + ) -> MagicMock: + nonlocal subscription_callback + assert event_filter is EventType.NETWORK_TOPOLOGY_UPDATED + subscription_callback = callback + return unsubscribe + + matter_client.subscribe_events.side_effect = capture_subscription + + during_fetch = _mock_topology() + during_fetch.collected_at = 1767888030000 + + async def fetch_topology() -> NetworkTopology: + # an update arriving while the initial fetch is in flight is buffered + assert subscription_callback is not None + subscription_callback(EventType.NETWORK_TOPOLOGY_UPDATED, during_fetch) + return topology + + matter_client.get_network_topology = AsyncMock(side_effect=fetch_topology) + + ws_client = await hass_ws_client(hass) + await ws_client.send_json({ID: 1, TYPE: "matter/subscribe_network_topology"}) + msg = await ws_client.receive_json() + + assert msg["success"] + matter_client.get_network_topology.assert_called_once_with() + assert subscription_callback is not None + + # the initial snapshot is pushed as the first event + msg = await ws_client.receive_json() + assert msg["type"] == "event" + assert msg["event"] == _expected_topology(topology, [entry.id, None, None]) + + # the update buffered during the fetch is flushed right after + msg = await ws_client.receive_json() + assert msg["type"] == "event" + assert msg["event"] == _expected_topology(during_fetch, [entry.id, None, None]) + + # a topology update from the server is forwarded to the subscription + updated = _mock_topology() + updated.collected_at = 1767888060000 + updated.nodes = topology.nodes[:1] + updated.connections = [] + subscription_callback(EventType.NETWORK_TOPOLOGY_UPDATED, updated) + msg = await ws_client.receive_json() + + assert msg["type"] == "event" + assert msg["event"] == _expected_topology(updated, [entry.id]) + + await ws_client.send_json({ID: 2, TYPE: "unsubscribe_events", "subscription": 1}) + msg = await ws_client.receive_json() + + assert msg["success"] + unsubscribe.assert_called_once_with() + + +@pytest.mark.usefixtures("integration") +async def test_subscribe_network_topology_fetch_failure( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + matter_client: MagicMock, +) -> None: + """Test the event subscription is cleaned up when the initial fetch fails.""" + matter_client.server_info.schema_version = 13 + unsubscribe = MagicMock() + + def capture_subscription( + callback: Callable[[EventType, NetworkTopology], None], + event_filter: EventType | None = None, + node_filter: int | None = None, + attr_path_filter: str | None = None, + ) -> MagicMock: + return unsubscribe + + matter_client.subscribe_events.side_effect = capture_subscription + matter_client.get_network_topology = AsyncMock( + side_effect=ServerVersionTooOld("Command not available") + ) + + ws_client = await hass_ws_client(hass) + await ws_client.send_json({ID: 1, TYPE: "matter/subscribe_network_topology"}) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "not_supported" + unsubscribe.assert_called_once_with() From 1ace03eeb8b93ace142886d40024460240d0d5b6 Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Wed, 19 Aug 2026 08:20:09 +0200 Subject: [PATCH 2/8] Bump pyatmo to 9.9.0 (#179517) --- homeassistant/components/netatmo/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 628b241e5725f..a15b8937ff7a6 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -13,6 +13,6 @@ "iot_class": "cloud_polling", "loggers": ["pyatmo"], "quality_scale": "bronze", - "requirements": ["pyatmo==9.7.0"], + "requirements": ["pyatmo==9.9.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index d9bfbf76eaa40..32dbda8f6cb65 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2053,7 +2053,7 @@ pyaqvify==0.0.12 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==9.7.0 +pyatmo==9.9.0 # homeassistant.components.apple_tv pyatv==0.18.0 From 1e1e3d705d13c6c37d7f3e86a8ff19e680970959 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 19 Aug 2026 08:31:16 +0200 Subject: [PATCH 3/8] Fix quality_scale for hvv_departures (#179528) --- homeassistant/components/hvv_departures/quality_scale.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/hvv_departures/quality_scale.yaml b/homeassistant/components/hvv_departures/quality_scale.yaml index 09133a728aae7..3b541e54e3cd5 100644 --- a/homeassistant/components/hvv_departures/quality_scale.yaml +++ b/homeassistant/components/hvv_departures/quality_scale.yaml @@ -37,11 +37,17 @@ rules: docs-actions: status: exempt comment: The integration does not provide any custom actions. + docs-conditions: + status: exempt + comment: The integration does not have any conditions. docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: status: todo comment: The integration documentation does not include a removal instructions section. + docs-triggers: + status: exempt + comment: The integration does not have any triggers. entity-event-setup: status: exempt comment: The integration entities do not subscribe to any events. From f84ff4a095c09cf7096b712c260e677990e98603 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 19 Aug 2026 16:38:18 +1000 Subject: [PATCH 4/8] Clear stuck Teslemetry update progress after an offline install (#179479) Co-authored-by: Josef Zweck --- homeassistant/components/teslemetry/update.py | 33 ++++++-- tests/components/teslemetry/test_update.py | 82 +++++++++++++++++++ 2 files changed, 107 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py index c592070bba43c..94747e786e505 100644 --- a/homeassistant/components/teslemetry/update.py +++ b/homeassistant/components/teslemetry/update.py @@ -158,12 +158,6 @@ async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() if (state := await self.async_get_last_state()) is not None: - self._attr_in_progress = state.attributes.get( - UpdateEntityStateAttribute.IN_PROGRESS, False - ) - self._attr_update_percentage = state.attributes.get( - UpdateEntityStateAttribute.UPDATE_PERCENTAGE - ) self._attr_installed_version = state.attributes.get( UpdateEntityStateAttribute.INSTALLED_VERSION ) @@ -175,7 +169,16 @@ async def async_added_to_hass(self) -> None: "supported_features", self._attr_supported_features ) ) - self._scheduled = self._attr_in_progress + # A restored in-progress flag with installed == latest is a missed + # completion; restoring it would re-strand the entity every restart. + if not self._up_to_date: + self._attr_in_progress = state.attributes.get( + UpdateEntityStateAttribute.IN_PROGRESS, False + ) + self._attr_update_percentage = state.attributes.get( + UpdateEntityStateAttribute.UPDATE_PERCENTAGE + ) + self._scheduled = self._attr_in_progress self.async_write_ha_state() self.async_on_remove( @@ -248,8 +251,19 @@ def _async_handle_version(self, value: str | None) -> None: if value is not None: self._attr_installed_version = value.split(" ")[0] + # A new installed version can be the only signal that an offline + # install finished, so re-evaluate any lingering scheduled flag. + self._async_update_progress() self.async_write_ha_state() + @property + def _up_to_date(self) -> bool: + """Return True when the installed version matches the known latest version.""" + return ( + self._attr_installed_version is not None + and self._attr_installed_version == self._attr_latest_version + ) + def _async_update_progress(self) -> None: """Update the progress of the update.""" @@ -259,6 +273,9 @@ def _async_update_progress(self) -> None: elif 10 < self._install_percentage < 100: self._attr_in_progress = True self._attr_update_percentage = self._install_percentage + elif self._scheduled and not self._up_to_date: + self._attr_in_progress = True + self._attr_update_percentage = None else: - self._attr_in_progress = self._scheduled + self._attr_in_progress = False self._attr_update_percentage = None diff --git a/tests/components/teslemetry/test_update.py b/tests/components/teslemetry/test_update.py index 6c3d48f61cdbb..0f5b51bb08aa6 100644 --- a/tests/components/teslemetry/test_update.py +++ b/tests/components/teslemetry/test_update.py @@ -281,6 +281,88 @@ async def test_update_streaming_restore( assert state.attributes["update_percentage"] == 42 +async def test_update_streaming_restore_completed_not_in_progress( + hass: HomeAssistant, + mock_vehicle_data: AsyncMock, +) -> None: + """Test a stored in-progress flag is dropped when installed == latest. + + Reproduces a stuck "Installing" tile: the completion event never streamed + because the vehicle went offline mid-install, so the last state kept + in_progress with no percentage while the versions already matched. + """ + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + entity_id = "update.test_update" + mock_restore_cache( + hass, + ( + State( + entity_id, + STATE_ON, + attributes={ + "in_progress": True, + "update_percentage": None, + "installed_version": "2026.26.1", + "latest_version": "2026.26.1", + }, + ), + ), + ) + + await setup_platform(hass, [Platform.UPDATE]) + + state = hass.states.get(entity_id) + assert state.attributes["in_progress"] is False + assert state.attributes["update_percentage"] is None + + +async def test_update_streaming_completed_while_scheduled( + hass: HomeAssistant, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Test a scheduled flag does not strand in_progress once installed == latest.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + await setup_platform(hass, [Platform.UPDATE]) + + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: 1735689600, + Signal.SOFTWARE_UPDATE_VERSION: "2025.2.1", + Signal.VERSION: "2025.1.1", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_update") + assert state.attributes["in_progress"] is True + + # Install finishes while offline: only the new installed version streams, + # matching latest. The lingering scheduled flag must not keep it in progress. + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: None, + Signal.VERSION: "2025.2.1", + }, + "createdAt": "2024-10-04T10:45:18.537Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_update") + assert state.attributes["in_progress"] is False + assert state.attributes["update_percentage"] is None + + @pytest.mark.parametrize( ("data", "expected_in_progress", "expected_percentage"), [ From 8694fc917abdf19d74f0da3fa43f1ce430bc7c11 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Aug 2026 02:30:02 -0500 Subject: [PATCH 5/8] Bump aioesphomeapi to 45.12.0 (#179521) Co-authored-by: Josef Zweck --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index e86f6ce3f1be1..f428fc5b9d65b 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==45.6.1", + "aioesphomeapi==45.12.0", "esphome-dashboard-api==1.4.0", "bleak-esphome==3.9.7" ], diff --git a/requirements_all.txt b/requirements_all.txt index 32dbda8f6cb65..5b5d06d20dea6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -260,7 +260,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==45.6.1 +aioesphomeapi==45.12.0 # homeassistant.components.matrix # homeassistant.components.slack From 90aa15133f66b7ef52f8d804dbfe0a69d6b60c12 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 19 Aug 2026 10:21:07 +0200 Subject: [PATCH 6/8] Deprecate the mop drying binary sensor in favor of the switch (#178932) --- .../components/roborock/binary_sensor.py | 52 +++-- .../components/roborock/strings.json | 8 + homeassistant/components/roborock/util.py | 101 ++++++++++ .../snapshots/test_binary_sensor.ambr | 102 ---------- .../components/roborock/test_binary_sensor.py | 186 +++++++++++++++++- 5 files changed, 334 insertions(+), 115 deletions(-) create mode 100644 homeassistant/components/roborock/util.py diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index ca86acc60ddbe..b966717276ff5 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -14,12 +14,14 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.const import ATTR_BATTERY_CHARGING, EntityCategory +from homeassistant.const import ATTR_BATTERY_CHARGING, EntityCategory, Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType +from .const import DOMAIN from .coordinator import ( RoborockConfigEntry, RoborockCoordinatorType, @@ -29,6 +31,7 @@ ) from .entity import RoborockCoordinatedEntityA01, RoborockCoordinatedEntityV1 from .models import DeviceState +from .util import deprecate_entity PARALLEL_UPDATES = 0 @@ -56,17 +59,6 @@ class RoborockBinarySensorDescriptionA01(BinarySensorEntityDescription): BINARY_SENSOR_DESCRIPTIONS = [ - RoborockBinarySensorDescription( - key="dry_status", - translation_key="mop_drying_status", - device_class=BinarySensorDeviceClass.RUNNING, - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data.status.dry_status, - is_dock_entity=True, - support_fn=lambda api: api.device_features.is_field_supported( - StatusV2, StatusField.DRY_STATUS - ), - ), RoborockBinarySensorDescription( key="water_box_carriage_status", translation_key="mop_attached", @@ -147,6 +139,16 @@ class RoborockBinarySensorDescriptionA01(BinarySensorEntityDescription): ] +MOP_DRYING_BINARY_SENSOR_DESCRIPTION = RoborockBinarySensorDescription( + key="dry_status", + translation_key="mop_drying_status", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.status.dry_status, + is_dock_entity=True, +) + + ZEO_BINARY_SENSOR_DESCRIPTIONS: list[RoborockBinarySensorDescriptionA01] = [ RoborockBinarySensorDescriptionA01( key="detergent_empty", @@ -174,6 +176,7 @@ async def async_setup_entry( ) -> None: """Set up the Roborock vacuum binary sensors.""" coordinators = config_entry.runtime_data + entity_registry = er.async_get(hass) @callback def async_add_coordinator_entities( @@ -187,6 +190,31 @@ def async_add_coordinator_entities( for description in BINARY_SENSOR_DESCRIPTIONS if description.support_fn(coordinator.properties_api) ) + mop_drying_unique_id = ( + f"{MOP_DRYING_BINARY_SENSOR_DESCRIPTION.key}_{coordinator.duid_slug}" + ) + mop_drying_issue_id = f"deprecated_mop_drying_{coordinator.duid_slug}" + if not coordinator.properties_api.device_features.dock_features.is_dryable: + # The sensor was created for every device reporting the drying + # status data point, so a dock that cannot dry always read off. + if entity_id := entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, mop_drying_unique_id + ): + entity_registry.async_remove(entity_id) + ir.async_delete_issue(hass, DOMAIN, mop_drying_issue_id) + elif deprecate_entity( + hass, + entity_registry, + platform_domain=Platform.BINARY_SENSOR, + entity_unique_id=mop_drying_unique_id, + issue_id=mop_drying_issue_id, + translation_key="deprecated_mop_drying", + ): + entities.append( + RoborockBinarySensorEntity( + coordinator, MOP_DRYING_BINARY_SENSOR_DESCRIPTION + ) + ) elif isinstance(coordinator, RoborockWashingMachineUpdateCoordinator): entities.extend( RoborockBinarySensorEntityA01(coordinator, description) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 105adcb402326..b5b4cffb4e995 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -738,6 +738,14 @@ "cloud_api_used": { "description": "The Roborock integration is unable to connect directly to {device_name} and falling back to the cloud API. This is not recommended as it can lead to rate limiting. Please make your vacuum accessible on the local network by your Home Assistant instance.", "title": "Cloud API used" + }, + "deprecated_mop_drying": { + "description": "The `{entity_id}` ({entity_name}) binary sensor is deprecated and has been replaced by the **Mop drying** switch, which reports the same state and can also start and stop drying.\n\nUpdate any dashboards, templates, automations or scripts to use the new switch entity, then disable `{entity_id}` to have it removed.", + "title": "The Roborock mop drying binary sensor is deprecated" + }, + "deprecated_mop_drying_scripts": { + "description": "The `{entity_id}` ({entity_name}) binary sensor is deprecated and has been replaced by the **Mop drying** switch, which reports the same state and can also start and stop drying.\n\nIt is still used in the following automations or scripts:\n{items}\n\nUpdate them to use the new switch entity, then disable `{entity_id}` to have it removed.", + "title": "[%key:component::roborock::issues::deprecated_mop_drying::title%]" } }, "options": { diff --git a/homeassistant/components/roborock/util.py b/homeassistant/components/roborock/util.py new file mode 100644 index 0000000000000..130e367cbe05e --- /dev/null +++ b/homeassistant/components/roborock/util.py @@ -0,0 +1,101 @@ +"""Utility helpers for the Roborock integration.""" + +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) + +from .const import DOMAIN + +# Version in which deprecated entities will be removed. +DEPRECATED_REMOVAL_VERSION = "2027.3.0" + + +def deprecate_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + *, + platform_domain: str, + entity_unique_id: str, + issue_id: str, + translation_key: str, +) -> bool: + """Handle deprecation of an entity that has been replaced. + + Return True if the deprecated entity should still be set up, which is the + case while it exists in the entity registry. A repair issue informs the user + about the replacement and the removal date; when the entity is still used by + automations or scripts they are listed in the issue. The entity is removed + once the user disables it and nothing references it anymore. New + installations never create the entity. + """ + entity_id = entity_registry.async_get_entity_id( + platform_domain, DOMAIN, entity_unique_id + ) + if entity_id is None: + async_delete_issue(hass, DOMAIN, issue_id) + return False + + entity_entry = entity_registry.async_get(entity_id) + if entity_entry is None: + async_delete_issue(hass, DOMAIN, issue_id) + return False + + items = _automations_and_scripts_using_entity(hass, entity_registry, entity_id) + + if entity_entry.disabled and not items: + entity_registry.async_remove(entity_id) + async_delete_issue(hass, DOMAIN, issue_id) + return False + + placeholders = { + "entity_id": entity_id, + "entity_name": entity_entry.name or entity_entry.original_name or entity_id, + } + if items: + translation_key = f"{translation_key}_scripts" + placeholders["items"] = "\n".join(items) + + async_create_issue( + hass, + DOMAIN, + issue_id, + breaks_in_ha_version=DEPRECATED_REMOVAL_VERSION, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders=placeholders, + ) + return True + + +def _automations_and_scripts_using_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + entity_id: str, +) -> list[str]: + """Return markdown list items for automations and scripts using an entity.""" + automations = automations_with_entity(hass, entity_id) + scripts = scripts_with_entity(hass, entity_id) + if not automations and not scripts: + return [] + + items: list[str] = [] + for integration, used_entities in ( + ("automation", automations), + ("script", scripts), + ): + for used_entity_id in used_entities: + if entry := entity_registry.async_get(used_entity_id): + items.append( + f"- [{entry.original_name}](/config/{integration}/edit/{entry.unique_id})" + ) + else: + items.append(f"- `{used_entity_id}`") + + return items diff --git a/tests/components/roborock/snapshots/test_binary_sensor.ambr b/tests/components/roborock/snapshots/test_binary_sensor.ambr index 52230939514b5..c08526c7f3b0f 100644 --- a/tests/components/roborock/snapshots/test_binary_sensor.ambr +++ b/tests/components/roborock/snapshots/test_binary_sensor.ambr @@ -254,57 +254,6 @@ 'state': 'on', }) # --- -# name: test_binary_sensors[binary_sensor.roborock_s7_2_dock_mop_drying-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.roborock_s7_2_dock_mop_drying', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Mop drying', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Mop drying', - 'platform': 'roborock', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'mop_drying_status', - 'unique_id': 'dry_status_device_2', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[binary_sensor.roborock_s7_2_dock_mop_drying-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'running', - : 'Roborock S7 2 Dock Mop drying', - }), - 'context': , - 'entity_id': 'binary_sensor.roborock_s7_2_dock_mop_drying', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_binary_sensors[binary_sensor.roborock_s7_2_mop_attached-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -713,57 +662,6 @@ 'state': 'on', }) # --- -# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_dock_mop_drying-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.roborock_s7_maxv_dock_mop_drying', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Mop drying', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Mop drying', - 'platform': 'roborock', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'mop_drying_status', - 'unique_id': 'dry_status_abc123', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_dock_mop_drying-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'running', - : 'Roborock S7 MaxV Dock Mop drying', - }), - 'context': , - 'entity_id': 'binary_sensor.roborock_s7_maxv_dock_mop_drying', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_binary_sensors[binary_sensor.roborock_s7_maxv_mop_attached-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py index e2e5f0c28f456..7eb19a554d6f5 100644 --- a/tests/components/roborock/test_binary_sensor.py +++ b/tests/components/roborock/test_binary_sensor.py @@ -4,12 +4,17 @@ from typing import Any import pytest +from roborock.data import RoborockDockTypeCode +from roborock.device_features import RoborockDockFeatures from roborock.exceptions import RoborockException from syrupy.assertion import SnapshotAssertion +from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN +from homeassistant.components.roborock.const import DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component from .conftest import FakeDevice @@ -122,3 +127,182 @@ async def test_zeo_request_protocols_filtered_by_schema( # Verify that the second Zeo device has detergent entities but NOT softener entities assert hass.states.get("binary_sensor.zeo_two_detergent") is not None assert hass.states.get("binary_sensor.zeo_two_softener") is None + + +@pytest.fixture +def dock_type(request: pytest.FixtureRequest, fake_vacuum: FakeDevice) -> None: + """Report the parametrized dock type for the fake vacuum.""" + fake_vacuum.v1_properties.device_features.dock_features = ( + RoborockDockFeatures.from_dock_type(request.param) + ) + + +MOP_DRYING_UNIQUE_ID = "dry_status_abc123" +MOP_DRYING_ISSUE_ID = "deprecated_mop_drying_abc123" +MOP_DRYING_ENTITY_ID = "binary_sensor.roborock_s7_maxv_dock_mop_drying" + + +def register_mop_drying_sensor( + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, + disabled_by: er.RegistryEntryDisabler | None = None, +) -> None: + """Register the mop drying binary sensor as an existing installation would have.""" + entity_registry.async_get_or_create( + Platform.BINARY_SENSOR, + DOMAIN, + MOP_DRYING_UNIQUE_ID, + config_entry=config_entry, + suggested_object_id="roborock_s7_maxv_dock_mop_drying", + disabled_by=disabled_by, + ) + + +async def test_mop_drying_sensor_not_created_for_new_installs( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + setup_entry: MockConfigEntry, +) -> None: + """Test the deprecated mop drying sensor is not created on a fresh install.""" + assert hass.states.get(MOP_DRYING_ENTITY_ID) is None + assert ( + entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, MOP_DRYING_UNIQUE_ID + ) + is None + ) + assert (DOMAIN, MOP_DRYING_ISSUE_ID) not in issue_registry.issues + + +async def test_mop_drying_sensor_deprecated( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Test an existing mop drying sensor is kept and raises a repair issue.""" + register_mop_drying_sensor(entity_registry, mock_roborock_entry) + + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(MOP_DRYING_ENTITY_ID).state == "off" + assert (DOMAIN, MOP_DRYING_ISSUE_ID) in issue_registry.issues + + +@pytest.mark.parametrize( + "dock_type", [RoborockDockTypeCode.o1_dock], indirect=True, ids=["collect-only"] +) +@pytest.mark.usefixtures("dock_type") +async def test_mop_drying_sensor_removed_for_dock_without_drying( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Test the sensor is removed without a repair issue when the dock cannot dry.""" + register_mop_drying_sensor(entity_registry, mock_roborock_entry) + + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(MOP_DRYING_ENTITY_ID) is None + assert ( + entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, MOP_DRYING_UNIQUE_ID + ) + is None + ) + assert (DOMAIN, MOP_DRYING_ISSUE_ID) not in issue_registry.issues + + +async def test_mop_drying_repair_cleared_when_dock_replaced( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + mock_roborock_entry: MockConfigEntry, + fake_vacuum: FakeDevice, +) -> None: + """Test the repair issue is cleared when the dock no longer supports drying.""" + register_mop_drying_sensor(entity_registry, mock_roborock_entry) + + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + assert (DOMAIN, MOP_DRYING_ISSUE_ID) in issue_registry.issues + + fake_vacuum.v1_properties.device_features.dock_features = ( + RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.o1_dock) + ) + await hass.config_entries.async_reload(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + assert ( + entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, MOP_DRYING_UNIQUE_ID + ) + is None + ) + assert (DOMAIN, MOP_DRYING_ISSUE_ID) not in issue_registry.issues + + +async def test_mop_drying_sensor_removed_when_disabled( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Test a disabled mop drying sensor is removed and the repair issue cleared.""" + register_mop_drying_sensor( + entity_registry, mock_roborock_entry, er.RegistryEntryDisabler.USER + ) + + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + assert ( + entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, MOP_DRYING_UNIQUE_ID + ) + is None + ) + assert (DOMAIN, MOP_DRYING_ISSUE_ID) not in issue_registry.issues + + +async def test_mop_drying_sensor_kept_when_used_by_automation( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Test a mop drying sensor used by an automation is kept and the usage listed.""" + register_mop_drying_sensor( + entity_registry, mock_roborock_entry, er.RegistryEntryDisabler.USER + ) + assert await async_setup_component( + hass, + AUTOMATION_DOMAIN, + { + AUTOMATION_DOMAIN: { + "alias": "test_automation", + "triggers": { + "trigger": "state", + "entity_id": MOP_DRYING_ENTITY_ID, + }, + "actions": {"action": "notify.notify", "data": {}}, + } + }, + ) + + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + assert ( + entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, MOP_DRYING_UNIQUE_ID + ) + is not None + ) + issue = issue_registry.async_get_issue(DOMAIN, MOP_DRYING_ISSUE_ID) + assert issue.translation_key == "deprecated_mop_drying_scripts" From d1630084bff9c5f28e9f0723e56d67e88f03114a Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Wed, 19 Aug 2026 17:47:16 +0900 Subject: [PATCH 7/8] Use homeassistant.util.dt.now instead of datetime.now in lg_thinq (#179467) Co-authored-by: YunseonPark-LGE --- homeassistant/components/lg_thinq/sensor.py | 10 ++-------- tests/components/lg_thinq/test_sensor.py | 10 +++++----- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index c7ed598544cf6..54cdfb5903e4c 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -747,10 +747,7 @@ def _update_status(self) -> None: value = self.data.value if isinstance(value, time): - # pylint: disable-next=home-assistant-enforce-now - local_now = datetime.now( - tz=dt_util.get_time_zone(self.coordinator.hass.config.time_zone) - ) + local_now = dt_util.now() self._device_state = ( self.coordinator.data[self._device_state_id].value if self._device_state_id in self.coordinator.data @@ -865,10 +862,7 @@ async def async_update(self, now: datetime | None = None) -> None: async def _async_update_and_schedule(self) -> None: """Update the state of the sensor.""" - # pylint: disable-next=home-assistant-enforce-now - local_now = datetime.now( - dt_util.get_time_zone(self.coordinator.hass.config.time_zone) - ) + local_now = dt_util.now() next_update = local_now + self.entity_description.update_interval if ( self.coordinator.update_energy_at_time_of_day is not None diff --git a/tests/components/lg_thinq/test_sensor.py b/tests/components/lg_thinq/test_sensor.py index 4bd59cb1b5290..a59171589b9d0 100644 --- a/tests/components/lg_thinq/test_sensor.py +++ b/tests/components/lg_thinq/test_sensor.py @@ -34,7 +34,7 @@ async def test_sensor_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" - hass.config.time_zone = "UTC" + await hass.config.async_set_time_zone("UTC") with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.SENSOR]): await setup_integration(hass, mock_config_entry) @@ -61,7 +61,7 @@ async def test_update_energy_entity( freezer: FrozenDateTimeFactory, ) -> None: """Test update energy entity.""" - hass.config.time_zone = "UTC" + await hass.config.async_set_time_zone("UTC") with patch( "homeassistant.components.lg_thinq.sensor.random.randint", return_value=1 ): @@ -94,7 +94,7 @@ async def test_energy_today_updates_hourly( freezer: FrozenDateTimeFactory, ) -> None: """Test that energy_today sensor updates every hour, not once per day.""" - hass.config.time_zone = "UTC" + await hass.config.async_set_time_zone("UTC") await setup_integration(hass, mock_config_entry) entity_id = "sensor.test_air_conditioner_energy_today" @@ -126,7 +126,7 @@ async def test_energy_today_last_reset_set_on_first_fetch( freezer: FrozenDateTimeFactory, ) -> None: """Test last_reset is set to midnight of the fetched day after first successful fetch.""" - hass.config.time_zone = "UTC" + await hass.config.async_set_time_zone("UTC") await setup_integration(hass, mock_config_entry) entity_id = "sensor.test_air_conditioner_energy_today" @@ -160,7 +160,7 @@ async def test_energy_today_last_reset_advances_on_new_day_fetch( freezer: FrozenDateTimeFactory, ) -> None: """Test last_reset advances to the new day's midnight only when its data is fetched.""" - hass.config.time_zone = "UTC" + await hass.config.async_set_time_zone("UTC") await setup_integration(hass, mock_config_entry) entity_id = "sensor.test_air_conditioner_energy_today" From c97725255254faffe040b67ab76b745473819299 Mon Sep 17 00:00:00 2001 From: robozdog <113970393+robozdog@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:08:17 +0300 Subject: [PATCH 8/8] Add AUTO profile support to Vallox (#179212) Co-authored-by: Claude Opus 5 (1M context) --- homeassistant/components/vallox/const.py | 2 ++ homeassistant/components/vallox/services.yaml | 1 + homeassistant/components/vallox/strings.json | 1 + tests/components/vallox/test_fan.py | 2 ++ tests/components/vallox/test_init.py | 2 ++ 5 files changed, 8 insertions(+) diff --git a/homeassistant/components/vallox/const.py b/homeassistant/components/vallox/const.py index 6c7c3154dada8..c452cfef2d6ee 100644 --- a/homeassistant/components/vallox/const.py +++ b/homeassistant/components/vallox/const.py @@ -28,6 +28,7 @@ "boost": VALLOX_PROFILE.BOOST, "fireplace": VALLOX_PROFILE.FIREPLACE, "extra": VALLOX_PROFILE.EXTRA, + "auto": VALLOX_PROFILE.AUTO, } VALLOX_PROFILE_TO_PRESET_MODE = { @@ -36,6 +37,7 @@ VALLOX_PROFILE.BOOST: "Boost", VALLOX_PROFILE.FIREPLACE: "Fireplace", VALLOX_PROFILE.EXTRA: "Extra", + VALLOX_PROFILE.AUTO: "Auto", } PRESET_MODE_TO_VALLOX_PROFILE = { diff --git a/homeassistant/components/vallox/services.yaml b/homeassistant/components/vallox/services.yaml index f2a55032b9314..1c821251f04a2 100644 --- a/homeassistant/components/vallox/services.yaml +++ b/homeassistant/components/vallox/services.yaml @@ -41,6 +41,7 @@ set_profile: - "boost" - "fireplace" - "extra" + - "auto" duration: required: false selector: diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json index 0b65834a3dd82..d2a2d81a57bbd 100644 --- a/homeassistant/components/vallox/strings.json +++ b/homeassistant/components/vallox/strings.json @@ -117,6 +117,7 @@ "selector": { "profile": { "options": { + "auto": "[%key:common::state::auto%]", "away": "[%key:common::state::not_home%]", "boost": "Boost", "extra": "Extra", diff --git a/tests/components/vallox/test_fan.py b/tests/components/vallox/test_fan.py index 03ca3bca36529..4cea197185000 100644 --- a/tests/components/vallox/test_fan.py +++ b/tests/components/vallox/test_fan.py @@ -56,6 +56,7 @@ async def test_fan_state( (Profile.AWAY, "Away"), (Profile.BOOST, "Boost"), (Profile.FIREPLACE, "Fireplace"), + (Profile.AUTO, "Auto"), ], ) async def test_fan_profile( @@ -168,6 +169,7 @@ async def test_turn_on_with_parameters( ("Away", Profile.HOME, [call(Profile.AWAY)]), ("Boost", Profile.HOME, [call(Profile.BOOST)]), ("Fireplace", Profile.HOME, [call(Profile.FIREPLACE)]), + ("Auto", Profile.HOME, [call(Profile.AUTO)]), ("Home", Profile.HOME, []), # No change ], ) diff --git a/tests/components/vallox/test_init.py b/tests/components/vallox/test_init.py index 61904ecdb44ba..d256e40f3c171 100644 --- a/tests/components/vallox/test_init.py +++ b/tests/components/vallox/test_init.py @@ -67,6 +67,8 @@ async def test_create_service( ("fireplace", 15), ("extra", None), ("extra", 15), + ("auto", None), + ("auto", 15), ], ) async def test_set_profile_service(