From c83b4994bf15faa15e5655096339b7c0ddcd18d1 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 2 Sep 2026 11:27:52 +0200 Subject: [PATCH 01/13] Update frontend to 20260826.3 (#181067) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pylint/plugins/pylint_home_assistant/generated/mdi_icons.py | 2 +- requirements_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 4945985ef4ba76..b5550f06e087ec 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -22,5 +22,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260826.2"] + "requirements": ["home-assistant-frontend==20260826.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8c184b6954d91d..158927b96f2ee3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -40,7 +40,7 @@ habluetooth==6.26.11 hass-nabucasa==2.7.0 hassil==3.12.0 home-assistant-bluetooth==2.0.0 -home-assistant-frontend==20260826.2 +home-assistant-frontend==20260826.3 home-assistant-intents==2026.8.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py index 64e16f0ff7cb2b..46170080b2cfdd 100644 --- a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py +++ b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py @@ -5,7 +5,7 @@ from typing import Final -FRONTEND_VERSION: Final[str] = "20260826.2" +FRONTEND_VERSION: Final[str] = "20260826.3" MDI_ICONS: Final[set[str]] = { "ab-testing", diff --git a/requirements_all.txt b/requirements_all.txt index fcef77b4d5b091..6dfaf326e5a2ae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1306,7 +1306,7 @@ hole==0.9.2 holidays==0.103 # homeassistant.components.frontend -home-assistant-frontend==20260826.2 +home-assistant-frontend==20260826.3 # homeassistant.components.conversation home-assistant-intents==2026.8.28 From bda4f1e439e5ee7ce26fc08de135c1c939670315 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 2 Sep 2026 11:31:06 +0200 Subject: [PATCH 02/13] Fix AdGuard Home device identifiers (#181027) --- homeassistant/components/adguard/__init__.py | 33 ++++++- homeassistant/components/adguard/entity.py | 9 +- tests/components/adguard/test_init.py | 99 +++++++++++++++++++- 3 files changed, 130 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py index ea6ad5a0bb37b0..ec806e3558fb7c 100644 --- a/homeassistant/components/adguard/__init__.py +++ b/homeassistant/components/adguard/__init__.py @@ -17,9 +17,9 @@ CONF_VERIFY_SSL, Platform, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType @@ -120,8 +120,37 @@ async def refresh(call: ServiceCall) -> None: return True +@callback +def _async_migrate_device_identifiers( + hass: HomeAssistant, entry: AdGuardConfigEntry +) -> None: + """Migrate devices identified by host, port and base path to the entry ID. + + Those identifiers had four parts, while the device registry only supports two. + """ + device_registry = dr.async_get(hass) + identifiers = {(DOMAIN, entry.entry_id)} + migrated = device_registry.async_get_device_by_identifier( + (DOMAIN, entry.entry_id), entry.entry_id + ) + + for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): + if device.identifiers == identifiers: + continue + + # Downgrading recreates the old device, leaving a duplicate behind. Its + # entities move back to the migrated device when the platforms set up. + if migrated is not None: + device_registry.async_remove_device(device.id) + continue + + device_registry.async_update_device(device.id, new_identifiers=identifiers) + + async def async_setup_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> bool: """Set up AdGuard Home from a config entry.""" + _async_migrate_device_identifiers(hass, entry) + session = async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]) adguard = AdGuardHome( entry.data[CONF_HOST], diff --git a/homeassistant/components/adguard/entity.py b/homeassistant/components/adguard/entity.py index a6460dea1aeb3f..c4bec6bb5960c9 100644 --- a/homeassistant/components/adguard/entity.py +++ b/homeassistant/components/adguard/entity.py @@ -61,14 +61,7 @@ def device_info(self) -> DeviceInfo: return DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={ - ( # type: ignore[arg-type] - DOMAIN, - self.adguard.host, - self.adguard.port, - self.adguard.base_path, - ) - }, + identifiers={(DOMAIN, self._entry.entry_id)}, manufacturer="AdGuard Team", name="AdGuard Home", sw_version=self.data.version, diff --git a/tests/components/adguard/test_init.py b/tests/components/adguard/test_init.py index 6cbedd76be2fbb..f55f7273fd559d 100644 --- a/tests/components/adguard/test_init.py +++ b/tests/components/adguard/test_init.py @@ -1,13 +1,15 @@ """Tests for the AdGuard Home.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from adguardhome import AdGuardHomeConnectionError import pytest +from homeassistant.components.adguard.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -39,3 +41,98 @@ async def test_setup_failed( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_device_identifiers( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_adguard: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the device is identified by a two part identifier.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.adguard.PLATFORMS", [Platform.SENSOR]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device_by_identifier( + (DOMAIN, mock_config_entry.entry_id), mock_config_entry.entry_id + ) + assert device is not None + assert device.identifiers == {(DOMAIN, mock_config_entry.entry_id)} + + +async def test_device_identifiers_migration( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_adguard: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the device created by an older version is migrated.""" + mock_config_entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "127.0.0.1", 3000, "/control")}, # type: ignore[arg-type] + name="AdGuard Home", + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + migrated = device_registry.async_get(device.id) + assert migrated is not None + assert migrated.identifiers == {(DOMAIN, mock_config_entry.entry_id)} + + +async def test_device_identifiers_migration_when_unavailable( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_adguard: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the device is migrated even when the instance cannot be reached.""" + mock_adguard.version.side_effect = AdGuardHomeConnectionError("Connection error") + + mock_config_entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "127.0.0.1", 3000, "/control")}, # type: ignore[arg-type] + name="AdGuard Home", + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + migrated = device_registry.async_get(device.id) + assert migrated is not None + assert migrated.identifiers == {(DOMAIN, mock_config_entry.entry_id)} + + +async def test_device_identifiers_migration_with_duplicate( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_adguard: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a device left behind by a downgrade is cleaned up.""" + mock_config_entry.add_to_hass(hass) + current = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name="AdGuard Home", + ) + duplicate = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "127.0.0.1", 3000, "/control")}, # type: ignore[arg-type] + name="AdGuard Home", + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert device_registry.async_get(duplicate.id) is None + assert device_registry.async_get(current.id) is not None From 72a982b33bff70ed7cd4a9623b209a6586fb8a7a Mon Sep 17 00:00:00 2001 From: Manu Date: Wed, 2 Sep 2026 11:51:01 +0200 Subject: [PATCH 03/13] Fix offline devices being removed in Xbox integration (#181045) --- homeassistant/components/xbox/__init__.py | 21 ++++++++++++ homeassistant/components/xbox/coordinator.py | 14 -------- tests/components/xbox/test_init.py | 36 ++++++++++++-------- 3 files changed, 42 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 0112b68f13b3d4..277471c9c8b4de 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -88,6 +88,27 @@ async def async_unload_entry(hass: HomeAssistant, entry: XboxConfigEntry) -> boo return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: XboxConfigEntry, + device_entry: dr.AnyDeviceEntry, +) -> bool: + """Remove a stale device from a config entry.""" + + return not any( + identifier + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + and ( + ( + isinstance(device_entry, dr.DeviceEntry) + and device_entry.entry_type == dr.DeviceEntryType.SERVICE + ) + or identifier[1] in config_entry.runtime_data.consoles.data + ) + ) + + async def async_migrate_entry(hass: HomeAssistant, entry: XboxConfigEntry) -> bool: """Migrate config entry.""" diff --git a/homeassistant/components/xbox/coordinator.py b/homeassistant/components/xbox/coordinator.py index d9b0766d70eb7d..bf6eb7f7360b71 100644 --- a/homeassistant/components/xbox/coordinator.py +++ b/homeassistant/components/xbox/coordinator.py @@ -20,8 +20,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -117,18 +115,6 @@ async def update_data(self) -> dict[str, SmartglassConsole]: "Found %d consoles: %s", len(consoles.result), consoles.model_dump() ) - device_reg = dr.async_get(self.hass) - identifiers = {(DOMAIN, console.id) for console in consoles.result} - for device in dr.async_entries_for_config_entry( - device_reg, self.config_entry.entry_id - ): - if ( - device.entry_type is not DeviceEntryType.SERVICE - and not set(device.identifiers) & identifiers - ): - _LOGGER.debug("Removing stale device %s", device.name) - device_reg.async_remove_device(device.id) - return {console.id: console for console in consoles.result} diff --git a/tests/components/xbox/test_init.py b/tests/components/xbox/test_init.py index 469a96b6f2b6f2..3b9ab66dd33ea7 100644 --- a/tests/components/xbox/test_init.py +++ b/tests/components/xbox/test_init.py @@ -23,12 +23,14 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, ) +from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, async_fire_time_changed, async_load_json_object_fixture, ) +from tests.typing import WebSocketGenerator @pytest.mark.usefixtures("xbox_live_client") @@ -204,9 +206,11 @@ async def test_dynamic_devices( xbox_live_client: AsyncMock, device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, + hass_ws_client: WebSocketGenerator, ) -> None: """Test adding of new and removal of stale devices at runtime.""" - + assert await async_setup_component(hass, "config", {}) + client = await hass_ws_client(hass) xbox_live_client.smartglass.get_console_list.return_value = SmartglassConsoleList( **await async_load_json_object_fixture( hass, "smartglass_console_list_empty.json", DOMAIN @@ -225,12 +229,6 @@ async def test_dynamic_devices( ) is None ) - assert ( - device_registry.async_get_device_by_identifier( - (DOMAIN, "HIJKLMN"), config_entry.entry_id - ) - is None - ) xbox_live_client.smartglass.get_console_list.return_value = SmartglassConsoleList( **await async_load_json_object_fixture( @@ -242,13 +240,15 @@ async def test_dynamic_devices( async_fire_time_changed(hass) await hass.async_block_till_done() - assert device_registry.async_get_device_by_identifier( - (DOMAIN, "ABCDEFG"), config_entry.entry_id - ) - assert device_registry.async_get_device_by_identifier( - (DOMAIN, "HIJKLMN"), config_entry.entry_id + assert ( + device := device_registry.async_get_device_by_identifier( + (DOMAIN, "ABCDEFG"), config_entry.entry_id + ) ) + response = await client.remove_device(device.id) + assert not response["success"] + xbox_live_client.smartglass.get_console_list.return_value = SmartglassConsoleList( **await async_load_json_object_fixture( hass, "smartglass_console_list_empty.json", DOMAIN @@ -259,15 +259,21 @@ async def test_dynamic_devices( async_fire_time_changed(hass) await hass.async_block_till_done() + response = await client.remove_device(device.id) + assert response["success"] + assert ( device_registry.async_get_device_by_identifier( (DOMAIN, "ABCDEFG"), config_entry.entry_id ) is None ) + + # Test that service devices cannot be removed assert ( - device_registry.async_get_device_by_identifier( - (DOMAIN, "HIJKLMN"), config_entry.entry_id + account := device_registry.async_get_device_by_identifier( + (DOMAIN, "271958441785640"), config_entry.entry_id ) - is None ) + response = await client.remove_device(account.id) + assert not response["success"] From ecae90e6ad3ddfb553f0ea9d7afcca057d4a76c7 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 2 Sep 2026 13:03:45 +0200 Subject: [PATCH 04/13] Update frontend to 20260826.4 (#181072) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pylint/plugins/pylint_home_assistant/generated/mdi_icons.py | 2 +- requirements_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index b5550f06e087ec..19cf273c6f5f60 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -22,5 +22,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260826.3"] + "requirements": ["home-assistant-frontend==20260826.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 158927b96f2ee3..30cfabe0a4fdab 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -40,7 +40,7 @@ habluetooth==6.26.11 hass-nabucasa==2.7.0 hassil==3.12.0 home-assistant-bluetooth==2.0.0 -home-assistant-frontend==20260826.3 +home-assistant-frontend==20260826.4 home-assistant-intents==2026.8.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py index 46170080b2cfdd..8b4f4959edd816 100644 --- a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py +++ b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py @@ -5,7 +5,7 @@ from typing import Final -FRONTEND_VERSION: Final[str] = "20260826.3" +FRONTEND_VERSION: Final[str] = "20260826.4" MDI_ICONS: Final[set[str]] = { "ab-testing", diff --git a/requirements_all.txt b/requirements_all.txt index 6dfaf326e5a2ae..847cbcfcae32aa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1306,7 +1306,7 @@ hole==0.9.2 holidays==0.103 # homeassistant.components.frontend -home-assistant-frontend==20260826.3 +home-assistant-frontend==20260826.4 # homeassistant.components.conversation home-assistant-intents==2026.8.28 From e78b25429227fb23e44b6c0eecd27b73a34606fd Mon Sep 17 00:00:00 2001 From: Balloob Bot Date: Wed, 2 Sep 2026 14:05:01 +0200 Subject: [PATCH 05/13] Ignore static add-on devices when attributing serial ports (#181074) Co-authored-by: Paulus Schoutsen Co-authored-by: Claude Fable 5.1 --- homeassistant/components/usb/consumers.py | 27 +++++++++++++++----- tests/components/usb/test_consumers.py | 30 ++++++++++++++++++++--- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/usb/consumers.py b/homeassistant/components/usb/consumers.py index 8dd32c97dda5e7..e58b4e36342601 100644 --- a/homeassistant/components/usb/consumers.py +++ b/homeassistant/components/usb/consumers.py @@ -1,6 +1,6 @@ """Attribution of serial ports to the integrations and apps using them.""" -from collections.abc import Mapping, Sequence +from collections.abc import Iterator, Mapping, Sequence import os import re from typing import Any @@ -156,14 +156,29 @@ async def _async_get_config_entry_consumers( return consumers +def _iter_option_device_paths(value: Any) -> Iterator[str]: + """Yield device paths configured anywhere in the options of an app.""" + if isinstance(value, str): + if value.startswith("/dev/"): + yield value + elif isinstance(value, Mapping): + for item in value.values(): + yield from _iter_option_device_paths(item) + elif isinstance(value, list): + for item in value: + yield from _iter_option_device_paths(item) + + @callback def _async_get_app_consumers( hass: HomeAssistant, ) -> dict[str, list[SerialPortConsumer]]: - """Return devices mapped into apps, either statically or through options. + """Return devices configured in the options of apps. - Supervisor resolves `device(subsystem=tty)` options into real devices, so device - paths that no longer exist are missing and non-serial devices are included. + The `devices` field of an app also lists the static devices of its manifest, + which are mapped into the container whether the app uses them or not, so only + options are evidence of a device being used. Options can refer to devices + that no longer exist or are not serial ports. """ if not is_hassio(hass): return {} @@ -179,7 +194,7 @@ def _async_get_app_consumers( if info is None: continue - for device in info["devices"]: + for device in _iter_option_device_paths(info["options"]): consumers.setdefault(device, []).append( SerialPortConsumer( kind="app", @@ -224,7 +239,7 @@ async def async_get_serial_port_consumers( consumers.setdefault(device, []).extend(path_consumers) for path, path_consumers in app_consumers.items(): - # Apps also map non-serial devices, only scanned ports are of interest + # Options can name non-serial devices, only scanned ports are of interest resolved_path = resolved[path] if resolved_path not in aliases: diff --git a/tests/components/usb/test_consumers.py b/tests/components/usb/test_consumers.py index e130bb46edcd89..d00993f118345b 100644 --- a/tests/components/usb/test_consumers.py +++ b/tests/components/usb/test_consumers.py @@ -439,17 +439,26 @@ async def test_app_consumers( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: - """Test detecting serial ports mapped into apps.""" + """Test detecting serial ports configured in the options of apps.""" apps_info = { "core_zwave_js": { "name": "Z-Wave JS", "state": "started", - "devices": [TTY_USB0_BY_ID, "/dev/dri/card0"], + "devices": [TTY_USB0], + "options": {"device": TTY_USB0_BY_ID, "gpu": "/dev/dri/card0"}, }, "some_app": { "name": "Some App", "state": "stopped", "devices": [TTY_USB1], + "options": {"serial": [{"port": TTY_USB0}]}, + }, + # Static devices of the manifest are mapped regardless of being used + "wmbusmeters": { + "name": "Wmbusmeters", + "state": "started", + "devices": [TTY_USB0, TTY_USB1], + "options": {"reset_config": False}, }, "uninstalled_app": None, } @@ -478,7 +487,15 @@ async def test_app_consumers( "domain": None, "config_entry_id": None, "slug": "core_zwave_js", - } + }, + { + "kind": "app", + "title": "Some App", + "active": False, + "domain": None, + "config_entry_id": None, + "slug": "some_app", + }, ], ), (ESPHOME_PORT, []), @@ -531,7 +548,12 @@ async def test_multiple_consumers( entry.add_to_hass(hass) apps_info = { - "some_app": {"name": "Some App", "state": "started", "devices": [TTY_USB0]} + "some_app": { + "name": "Some App", + "state": "started", + "devices": [TTY_USB0], + "options": {"device": TTY_USB0}, + } } with ( From 182cdb39e78a79cd58478ac159d84f9a6f817ca5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 2 Sep 2026 14:29:53 +0200 Subject: [PATCH 06/13] Deprecate unused helper function cover.is_closed (#164741) --- homeassistant/components/cover/__init__.py | 4 ++++ tests/components/cover/test_init.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index fe94a1d65efc5f..d7bdf90324c9da 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -25,6 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.deprecation import deprecated_function from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -91,6 +92,9 @@ ] +@deprecated_function( + "hass.states.is_state(entity_id, 'closed')", breaks_in_ha_version="2027.10" +) def is_closed(hass: HomeAssistant, entity_id: str) -> bool: """Return if the cover is closed based on the statemachine.""" return hass.states.is_state(entity_id, CoverState.CLOSED) diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index e6694a860cc967..7ccffa0ceeabfd 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -320,3 +320,19 @@ async def test_services_with_speed( blocking=True, ) assert ent2.last_kwargs == {"position": 49} + + +async def test_deprecated_is_closed( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the deprecated is_closed helper.""" + hass.states.async_set("cover.test", CoverState.CLOSED) + assert cover.is_closed(hass, "cover.test") is True + + hass.states.async_set("cover.test", CoverState.OPEN) + assert cover.is_closed(hass, "cover.test") is False + + assert ( + "The deprecated function is_closed was called. It will be removed in HA Core " + "2027.10. Use hass.states.is_state(entity_id, 'closed') instead" + ) in caplog.text From c5fae71f7064751e5a48174c13045a18ae6d25aa Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 2 Sep 2026 14:48:12 +0200 Subject: [PATCH 07/13] Fix potential deadlock in receive_file backup util (#181070) --- homeassistant/components/backup/util.py | 16 ++--- tests/components/backup/test_util.py | 85 +++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index 953b4ab4c102e4..f605b25e323bed 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -12,7 +12,6 @@ import threading from typing import IO, Any, cast -import aiohttp from securetar import ( InvalidPasswordError, SecureTarArchive, @@ -508,7 +507,7 @@ def backup(self) -> AgentBackup: async def receive_file( - hass: HomeAssistant, contents: aiohttp.BodyPartReader, path: Path + hass: HomeAssistant, stream: AsyncIterator[bytes], path: Path ) -> None: """Receive a file from a stream and write it to a file.""" queue: SimpleQueue[tuple[bytes, asyncio.Future[None] | None] | None] = SimpleQueue() @@ -526,10 +525,10 @@ def _sync_queue_consumer() -> None: fut: asyncio.Future[None] | None = None try: fut = hass.async_add_executor_job(_sync_queue_consumer) - megabytes_sending = 0 - while chunk := await contents.read_chunk(BUF_SIZE): - megabytes_sending += 1 - if megabytes_sending % 5 != 0: + chunks_sent = 0 + async for chunk in stream: + chunks_sent += 1 + if chunks_sent % 5 != 0: queue.put_nowait((chunk, None)) continue @@ -542,8 +541,9 @@ def _sync_queue_consumer() -> None: if fut.done(): # The executor job failed break - - queue.put_nowait(None) # terminate queue consumer finally: + # Always terminate the queue consumer, also if the stream raised or the + # task was cancelled. + queue.put_nowait(None) if fut is not None: await fut diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index b2be5c257997e6..aca3c937b9e69f 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -19,6 +19,7 @@ DecryptedBackupStreamer, EncryptedBackupStreamer, read_backup, + receive_file, suggested_filename, validate_password, ) @@ -784,3 +785,87 @@ def test_suggested_filename(name: str, resulting_filename: str) -> None: size=1234, ) assert suggested_filename(backup) == resulting_filename + + +# Bound receive_file awaits so a reintroduced deadlock fails fast instead of +# hanging the test run. +_RECEIVE_FILE_TIMEOUT = 10 + + +async def _stream_chunks( + chunks: list[bytes], error: Exception | None = None +) -> AsyncIterator[bytes]: + """Yield chunks, then optionally raise to simulate a broken upload stream.""" + for chunk in chunks: + yield chunk + if error is not None: + raise error + + +@pytest.mark.parametrize( + "chunks", + [ + pytest.param([], id="empty"), + pytest.param([b"single"], id="single_chunk"), + pytest.param([b"chunk1", b"chunk2", b"chunk3"], id="multi_chunk"), + # >5 chunks crosses the backpressure checkpoint (every 5th chunk). + pytest.param([f"chunk{i}".encode() for i in range(12)], id="many_chunks"), + ], +) +async def test_receive_file( + hass: HomeAssistant, tmp_path: Path, chunks: list[bytes] +) -> None: + """Test receiving a stream and writing it to a file.""" + path = tmp_path / "received.bin" + async with asyncio.timeout(_RECEIVE_FILE_TIMEOUT): + await receive_file(hass, _stream_chunks(chunks), path) + assert path.read_bytes() == b"".join(chunks) + + +async def test_receive_file_writer_error(hass: HomeAssistant, tmp_path: Path) -> None: + """Test an OSError from the file writer propagates without deadlocking.""" + # The parent directory does not exist, so opening the file for writing fails. + path = tmp_path / "missing" / "received.bin" + async with asyncio.timeout(_RECEIVE_FILE_TIMEOUT): + with pytest.raises(FileNotFoundError): + await receive_file(hass, _stream_chunks([b"data"] * 10), path) + + +async def test_receive_file_stream_error(hass: HomeAssistant, tmp_path: Path) -> None: + """Test a stream error propagates and the consumer still terminates.""" + path = tmp_path / "received.bin" + stream = _stream_chunks( + [b"chunk1", b"chunk2"], ConnectionResetError("Connection lost") + ) + async with asyncio.timeout(_RECEIVE_FILE_TIMEOUT): + with pytest.raises(ConnectionResetError): + await receive_file(hass, stream, path) + # The consumer drained the queued chunks and closed the file before the error + # surfaced, proving it terminated rather than deadlocked. + assert path.read_bytes() == b"chunk1chunk2" + + +async def test_receive_file_cancelled(hass: HomeAssistant, tmp_path: Path) -> None: + """Test cancelling mid-transfer propagates CancelledError without deadlocking.""" + path = tmp_path / "received.bin" + first_chunk_sent = asyncio.Event() + blocked = asyncio.Event() # never set, so the stream blocks until cancelled + + async def _blocking_stream() -> AsyncIterator[bytes]: + yield b"chunk1" + first_chunk_sent.set() + await blocked.wait() + + task = asyncio.create_task(receive_file(hass, _blocking_stream(), path)) + await first_chunk_sent.wait() + task.cancel() + + # asyncio.wait does not cancel on timeout, so a deadlocked task stays pending + # and the assertion fails fast instead of the run hanging. + _done, pending = await asyncio.wait({task}, timeout=_RECEIVE_FILE_TIMEOUT) + assert not pending + with pytest.raises(asyncio.CancelledError): + task.result() + # The first chunk was flushed and the file closed before cancellation + # completed, proving the consumer terminated rather than deadlocked. + assert path.read_bytes() == b"chunk1" From edb7dc8b0d032b2dd90f33974f5aebe2936a75eb Mon Sep 17 00:00:00 2001 From: John Pettitt Date: Wed, 2 Sep 2026 07:12:23 -0700 Subject: [PATCH 08/13] Complete Subaru config flow error tests to CREATE_ENTRY (#181033) Co-authored-by: Claude Fable 5 --- tests/components/subaru/test_config_flow.py | 144 ++++++++++++++------ 1 file changed, 102 insertions(+), 42 deletions(-) diff --git a/tests/components/subaru/test_config_flow.py b/tests/components/subaru/test_config_flow.py index 5d2d19798e9b3a..3e925b0283bb7b 100644 --- a/tests/components/subaru/test_config_flow.py +++ b/tests/components/subaru/test_config_flow.py @@ -41,15 +41,6 @@ } -async def test_user_form_init(user_form) -> None: - """Test the initial user form for first step of the config flow.""" - assert user_form["description_placeholders"] is None - assert user_form["errors"] is None - assert user_form["handler"] == DOMAIN - assert user_form["step_id"] == "user" - assert user_form["type"] is FlowResultType.FORM - - async def test_user_form_repeat_identifier(hass: HomeAssistant, user_form) -> None: """Test we handle repeat identifiers.""" entry = MockConfigEntry( @@ -86,7 +77,7 @@ async def test_user_form_cannot_connect(hass: HomeAssistant, user_form) -> None: async def test_user_form_invalid_auth(hass: HomeAssistant, user_form) -> None: - """Test we handle invalid auth.""" + """Test we handle invalid auth, and that the flow can still be completed afterward.""" with patch( MOCK_API_CONNECT, side_effect=InvalidCredentials("invalidAccount"), @@ -99,6 +90,18 @@ async def test_user_form_invalid_auth(hass: HomeAssistant, user_form) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_auth"} + with ( + patch(MOCK_API_CONNECT, return_value=True), + patch(MOCK_API_DEVICE_REGISTERED, new_callable=PropertyMock, return_value=True), + patch(MOCK_API_IS_PIN_REQUIRED, return_value=False), + patch(ASYNC_SETUP_ENTRY, return_value=True), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CREDS, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + async def test_user_form_pin_not_required( hass: HomeAssistant, two_factor_verify_form @@ -154,9 +157,21 @@ async def test_registered_pin_required(hass: HomeAssistant, user_form) -> None: patch(MOCK_API_IS_PIN_REQUIRED, return_value=True), ): mock_device_registered.return_value = True - await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( user_form["flow_id"], user_input=TEST_CREDS ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "pin" + + with ( + patch(MOCK_API_TEST_PIN, return_value=True), + patch(MOCK_API_UPDATE_SAVED_PIN, return_value=True), + patch(ASYNC_SETUP_ENTRY, return_value=True), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PIN: TEST_PIN} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_registered_no_pin_required(hass: HomeAssistant, user_form) -> None: @@ -167,11 +182,13 @@ async def test_registered_no_pin_required(hass: HomeAssistant, user_form) -> Non MOCK_API_DEVICE_REGISTERED, new_callable=PropertyMock ) as mock_device_registered, patch(MOCK_API_IS_PIN_REQUIRED, return_value=False), + patch(ASYNC_SETUP_ENTRY, return_value=True), ): mock_device_registered.return_value = True - await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( user_form["flow_id"], user_input=TEST_CREDS ) + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_two_factor_request_success( @@ -186,11 +203,24 @@ async def test_two_factor_request_success( patch(MOCK_API_2FA_CONTACTS, new_callable=PropertyMock) as mock_contacts, ): mock_contacts.return_value = MOCK_2FA_CONTACTS - await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( two_factor_start_form["flow_id"], user_input={config_flow.CONF_CONTACT_METHOD: "email@addr.com"}, ) assert len(mock_two_factor_request.mock_calls) == 1 + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "two_factor_validate" + + with ( + patch(MOCK_API_2FA_VERIFY, return_value=True), + patch(MOCK_API_IS_PIN_REQUIRED, return_value=False), + patch(ASYNC_SETUP_ENTRY, return_value=True), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={config_flow.CONF_VALIDATION_CODE: "123456"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_two_factor_request_fail( @@ -225,18 +255,30 @@ async def test_two_factor_verify_success( ) as mock_two_factor_verify, patch(MOCK_API_IS_PIN_REQUIRED, return_value=True) as mock_is_in_required, ): - await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( two_factor_verify_form["flow_id"], user_input={config_flow.CONF_VALIDATION_CODE: "123456"}, ) assert len(mock_two_factor_verify.mock_calls) == 1 assert len(mock_is_in_required.mock_calls) == 1 + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "pin" + + with ( + patch(MOCK_API_TEST_PIN, return_value=True), + patch(MOCK_API_UPDATE_SAVED_PIN, return_value=True), + patch(ASYNC_SETUP_ENTRY, return_value=True), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PIN: TEST_PIN} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_two_factor_verify_bad_format( hass: HomeAssistant, two_factor_verify_form ) -> None: - """Test two factor verification bad format.""" + """Test two factor verification bad format, and that the flow can still be completed afterward.""" with ( patch( MOCK_API_2FA_VERIFY, @@ -252,11 +294,22 @@ async def test_two_factor_verify_bad_format( assert len(mock_is_pin_required.mock_calls) == 0 assert result["errors"] == {"base": "bad_validation_code_format"} + with ( + patch(MOCK_API_2FA_VERIFY, return_value=True), + patch(MOCK_API_IS_PIN_REQUIRED, return_value=False), + patch(ASYNC_SETUP_ENTRY, return_value=True), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={config_flow.CONF_VALIDATION_CODE: "123456"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + async def test_two_factor_verify_fail( hass: HomeAssistant, two_factor_verify_form ) -> None: - """Test two factor verification failure.""" + """Test two factor verification failure, and that the flow can still be completed afterward.""" with ( patch( MOCK_API_2FA_VERIFY, @@ -272,25 +325,20 @@ async def test_two_factor_verify_fail( assert len(mock_is_pin_required.mock_calls) == 0 assert result["errors"] == {"base": "incorrect_validation_code"} - -async def test_pin_form_init(pin_form) -> None: - """Test the pin entry form for second step of the config flow.""" - expected = { - "data_schema": config_flow.PIN_SCHEMA, - "description_placeholders": None, - "errors": None, - "flow_id": mock.ANY, - "handler": DOMAIN, - "step_id": "pin", - "type": "form", - "last_step": None, - "preview": None, - } - assert pin_form == expected + with ( + patch(MOCK_API_2FA_VERIFY, return_value=True), + patch(MOCK_API_IS_PIN_REQUIRED, return_value=False), + patch(ASYNC_SETUP_ENTRY, return_value=True), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={config_flow.CONF_VALIDATION_CODE: "123456"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_pin_form_bad_pin_format(hass: HomeAssistant, pin_form) -> None: - """Test we handle invalid pin.""" + """Test we handle invalid pin, and that the flow can still be completed afterward.""" with ( patch( MOCK_API_TEST_PIN, @@ -308,6 +356,16 @@ async def test_pin_form_bad_pin_format(hass: HomeAssistant, pin_form) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "bad_pin_format"} + with ( + patch(MOCK_API_TEST_PIN, return_value=True), + patch(MOCK_API_UPDATE_SAVED_PIN, return_value=True), + patch(ASYNC_SETUP_ENTRY, return_value=True), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PIN: TEST_PIN} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + async def test_pin_form_success(hass: HomeAssistant, pin_form) -> None: """Test successful PIN entry.""" @@ -379,16 +437,11 @@ async def test_pin_form_incorrect_pin(hass: HomeAssistant, pin_form) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY -async def test_option_flow_form(options_form) -> None: - """Test config flow options form.""" - assert options_form["description_placeholders"] is None - assert options_form["errors"] is None - assert options_form["step_id"] == "init" - assert options_form["type"] is FlowResultType.FORM - - async def test_option_flow(hass: HomeAssistant, options_form) -> None: """Test config flow options.""" + assert options_form["type"] is FlowResultType.FORM + assert options_form["step_id"] == "init" + result = await hass.config_entries.options.async_configure( options_form["flow_id"], user_input={ @@ -404,9 +457,13 @@ async def test_option_flow(hass: HomeAssistant, options_form) -> None: @pytest.fixture async def user_form(hass: HomeAssistant) -> ConfigFlowResult: """Return initial form for Subaru config flow.""" - return await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] is None + return result @pytest.fixture @@ -455,10 +512,13 @@ async def pin_form( ), patch(MOCK_API_IS_PIN_REQUIRED, return_value=True), ): - return await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( two_factor_verify_form["flow_id"], user_input={config_flow.CONF_VALIDATION_CODE: "123456"}, ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "pin" + return result @pytest.fixture From 50d92a6685222a18f4cd891286251ef27a536e42 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 2 Sep 2026 16:17:38 +0200 Subject: [PATCH 09/13] Fix receive backup streaming (#181012) Co-authored-by: Erik --- homeassistant/components/backup/manager.py | 76 ++++++----- homeassistant/components/backup/util.py | 11 ++ tests/components/backup/test_manager.py | 140 ++++++++++++++++++++- 3 files changed, 196 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index bff31d7b50be2f..f345178dce9b49 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -72,8 +72,10 @@ from .util import ( DecryptedBackupStreamer, EncryptedBackupStreamer, + iter_upload_chunks, make_backup_dir, read_backup, + receive_file, validate_password, validate_password_stream, ) @@ -1004,7 +1006,6 @@ async def _async_receive_backup( contents: aiohttp.BodyPartReader, ) -> str: """Receive and store a backup file from upload.""" - contents.chunk_size = BUF_SIZE suggested_filename = contents.filename or "backup.tar" safe_filename = PureWindowsPath(suggested_filename).name if ( @@ -1022,7 +1023,7 @@ async def _async_receive_backup( ) written_backup = await self._reader_writer.async_receive_backup( agent_ids=agent_ids, - stream=contents, + stream=iter_upload_chunks(contents), suggested_filename=suggested_filename, ) self.async_on_backup_event( @@ -1958,6 +1959,47 @@ def is_excluded_by_filter(path: PurePath) -> bool: ) from err return (tar_file_path, stat_result.st_size) + async def _receive_and_move_backup( + self, + *, + agent_ids: list[str], + stream: AsyncIterator[bytes], + temp_file: Path, + ) -> tuple[AgentBackup, Path]: + """Receive the upload into temp_file, validate it, and move it into place. + + Remove temp_file on any failure, including cancellation from a client + disconnect, so a partial or unparsable upload does not orphan a + potentially large temp file. + """ + async_add_executor_job = self._hass.async_add_executor_job + try: + await receive_file(self._hass, stream, temp_file) + try: + backup = await async_add_executor_job(read_backup, temp_file) + except ( + OSError, + tarfile.TarError, + json.JSONDecodeError, + KeyError, + InvalidBackupFilename, + ) as err: + LOGGER.warning("Unable to parse backup %s: %s", temp_file, err) + raise + + manager = self._hass.data[DATA_MANAGER] + if self._local_agent_id in agent_ids: + local_agent = manager.local_backup_agents[self._local_agent_id] + tar_file_path = local_agent.get_new_backup_path(backup) + await async_add_executor_job(make_backup_dir, tar_file_path.parent) + await async_add_executor_job(shutil.move, temp_file, tar_file_path) + else: + tar_file_path = temp_file + except Exception, asyncio.CancelledError: + await async_add_executor_job(temp_file.unlink, True) + raise + return backup, tar_file_path + @override async def async_receive_backup( self, @@ -1971,33 +2013,9 @@ async def async_receive_backup( async_add_executor_job = self._hass.async_add_executor_job await async_add_executor_job(make_backup_dir, self.temp_backup_dir) - f = await async_add_executor_job(temp_file.open, "wb") - try: - async for chunk in stream: - await async_add_executor_job(f.write, chunk) - finally: - await async_add_executor_job(f.close) - - try: - backup = await async_add_executor_job(read_backup, temp_file) - except ( - OSError, - tarfile.TarError, - json.JSONDecodeError, - KeyError, - InvalidBackupFilename, - ) as err: - LOGGER.warning("Unable to parse backup %s: %s", temp_file, err) - raise - - manager = self._hass.data[DATA_MANAGER] - if self._local_agent_id in agent_ids: - local_agent = manager.local_backup_agents[self._local_agent_id] - tar_file_path = local_agent.get_new_backup_path(backup) - await async_add_executor_job(make_backup_dir, tar_file_path.parent) - await async_add_executor_job(shutil.move, temp_file, tar_file_path) - else: - tar_file_path = temp_file + backup, tar_file_path = await self._receive_and_move_backup( + agent_ids=agent_ids, stream=stream, temp_file=temp_file + ) async def send_backup() -> AsyncIterator[bytes]: f = await async_add_executor_job(tar_file_path.open, "rb") diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index f605b25e323bed..2cb423b07b483c 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -12,6 +12,7 @@ import threading from typing import IO, Any, cast +import aiohttp from securetar import ( InvalidPasswordError, SecureTarArchive, @@ -506,6 +507,16 @@ def backup(self) -> AgentBackup: return replace(self._backup, protected=True, size=self.size()) +async def iter_upload_chunks(contents: aiohttp.BodyPartReader) -> AsyncIterator[bytes]: + """Yield chunks of an uploaded file. + + Iterating a BodyPartReader reads the whole part into memory and enforces the + request's client_max_size limit; reading it in chunks does neither. + """ + while chunk := await contents.read_chunk(BUF_SIZE): + yield chunk + + async def receive_file( hass: HomeAssistant, stream: AsyncIterator[bytes], path: Path ) -> None: diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 5c11d1ab689392..243516c13a76d4 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Generator from dataclasses import replace from datetime import timedelta -from io import StringIO +from io import BytesIO, StringIO import json from pathlib import Path import re @@ -34,7 +34,7 @@ LocalBackupAgent, ) from homeassistant.components.backup.agent import BackupAgentError -from homeassistant.components.backup.const import DATA_MANAGER +from homeassistant.components.backup.const import BUF_SIZE, DATA_MANAGER from homeassistant.components.backup.manager import ( AddonErrorData, AddonInfo, @@ -50,6 +50,7 @@ UploadBackupEvent, WrittenBackup, ) +from homeassistant.components.http.server import MAX_CLIENT_SIZE from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -2017,6 +2018,141 @@ async def test_receive_backup( assert unlink_mock.call_count == temp_file_unlink_call_count +@pytest.mark.parametrize( + ("upload_size", "min_chunk_count"), + [ + pytest.param(1024, 1, id="small"), + pytest.param(MAX_CLIENT_SIZE + 1024, 2, id="above_max_client_size"), + ], +) +async def test_receive_large_backup( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + upload_size: int, + min_chunk_count: int, +) -> None: + """Test receiving a backup larger than the max request body size.""" + await setup_backup_integration(hass) + # Make sure we wait for Platform.EVENT and Platform.SENSOR to be fully processed, + # to avoid interference with the Path.open patching below which is used to verify + # that the file is written to the expected location. + await hass.async_block_till_done(True) + client = await hass_client() + open_mock = mock_open() + + with ( + patch("pathlib.Path.open", open_mock), + patch("homeassistant.components.backup.manager.make_backup_dir"), + patch("shutil.move"), + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + ): + data = FormData(quote_fields=False) + data.add_field( + "file", + BytesIO(b"\0" * upload_size), + filename="backup.tar", + content_type="application/octet-stream", + ) + resp = await client.post("/api/backup/upload?agent_id=backup.local", data=data) + await hass.async_block_till_done() + + assert resp.status == 201 + assert await resp.json() == {"backup_id": TEST_BACKUP_ABC123.backup_id} + written_chunks = [ + call.args[0] for call in open_mock.return_value.write.call_args_list + ] + assert sum(len(chunk) for chunk in written_chunks) == upload_size + # The file must be written in bounded chunks, not buffered into memory whole + assert len(written_chunks) >= min_chunk_count + assert max(len(chunk) for chunk in written_chunks) <= BUF_SIZE + + +class _DisconnectingBodyPartReader: + """Minimal BodyPartReader whose read_chunk raises after the first chunk. + + Models a client disconnect mid-upload: aiohttp sets a ConnectionResetError on + the request payload and cancels the handler when the connection is lost. + """ + + filename = "backup.tar" + + def __init__(self, chunk: bytes, error: Exception) -> None: + """Initialize the reader.""" + self._chunk = chunk + self._error = error + self._sent = False + + async def read_chunk(self, size: int) -> bytes: + """Return one chunk, then raise on the next read.""" + if self._sent: + raise self._error + self._sent = True + return self._chunk + + +async def test_receive_backup_stream_error_resets_state(hass: HomeAssistant) -> None: + """Test the backup manager returns to IDLE when the upload stream fails. + + A client disconnect (or other stream error) mid-upload must not wedge the + manager in a busy state; this drives the manager with a stream that raises + rather than a real socket disconnect, which the test client can't produce. + """ + await setup_backup_integration(hass) + await hass.async_block_till_done(True) + manager = hass.data[DATA_MANAGER] + contents = _DisconnectingBodyPartReader( + b"\0" * 1024, ConnectionResetError("Connection lost") + ) + + with ( + patch("pathlib.Path.open", mock_open()), + patch("homeassistant.components.backup.manager.make_backup_dir"), + patch("pathlib.Path.unlink") as unlink_mock, + ): + # Bound the await so a reintroduced deadlock fails fast instead of hanging. + async with asyncio.timeout(10): + with pytest.raises(ConnectionResetError): + await manager.async_receive_backup( + agent_ids=["backup.local"], contents=contents + ) + + assert manager.state is BackupManagerState.IDLE + # The partially written temp file is removed on the failed upload. + assert unlink_mock.call_count == 1 + + +async def test_receive_backup_unparsable_file_removed( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test the temp file is removed when the uploaded backup can't be parsed.""" + await setup_backup_integration(hass) + await hass.async_block_till_done(True) + client = await hass_client() + + with ( + patch("pathlib.Path.open", mock_open(read_data=b"test")), + patch("homeassistant.components.backup.manager.make_backup_dir"), + patch( + "homeassistant.components.backup.manager.read_backup", + side_effect=OSError("Boom"), + ), + patch("pathlib.Path.unlink") as unlink_mock, + ): + resp = await client.post( + "/api/backup/upload?agent_id=backup.local", + data={"file": StringIO("test")}, + ) + await hass.async_block_till_done() + + assert resp.status == 500 + # The unparsable temp file is removed exactly once. + assert unlink_mock.call_count == 1 + + async def test_receive_backup_valid_filename( hass: HomeAssistant, hass_client: ClientSessionGenerator, From 59064e3be4553a1a7210126e6b738a91b8d51060 Mon Sep 17 00:00:00 2001 From: kingces95 Date: Wed, 2 Sep 2026 04:45:55 -1000 Subject: [PATCH 10/13] Update Rachio zone state after commands (#179614) --- homeassistant/components/rachio/device.py | 20 ++- homeassistant/components/rachio/switch.py | 9 +- tests/components/rachio/conftest.py | 16 ++- tests/components/rachio/test_switch.py | 167 ++++++++++++++++++++++ 4 files changed, 207 insertions(+), 5 deletions(-) create mode 100644 tests/components/rachio/test_switch.py diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py index 5bf10c08f23ffa..56b3e22f90547a 100644 --- a/homeassistant/components/rachio/device.py +++ b/homeassistant/components/rachio/device.py @@ -9,7 +9,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) from .const import ( KEY_BASE_STATIONS, @@ -36,6 +40,13 @@ PERMISSION_ERROR = "7" type RachioConfigEntry = ConfigEntry[RachioPerson] +type RachioResponse = tuple[dict[str, Any], Any] + + +def raise_for_status(response: RachioResponse) -> None: + """Raise if a Rachio API response was not successful.""" + if not HTTPStatus.OK <= int(response[0][KEY_STATUS]) < HTTPStatus.MULTIPLE_CHOICES: + raise HomeAssistantError(f"API Error: {response}") class RachioPerson: @@ -258,9 +269,14 @@ def list_flex_schedules(self) -> list: """Return a list of flex schedules.""" return self._flex_schedules + def start_zone_watering(self, zone_id: str, duration: int) -> None: + """Start watering one zone connected to this controller.""" + raise_for_status(self.rachio.zone.start(zone_id, duration)) + _LOGGER.debug("Started watering zone %s on %s", zone_id, self) + def stop_watering(self) -> None: """Stop watering all zones connected to this controller.""" - self.rachio.device.stop_water(self.controller_id) + raise_for_status(self.rachio.device.stop_water(self.controller_id)) _LOGGER.debug("Stopped watering of all zones on %s", self) def pause_watering(self, duration) -> None: diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index 83025aa03c85c0..02acfb268e796a 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -368,7 +368,11 @@ def turn_on(self, **kwargs: Any) -> None: ) ) # The API limit is 3 hours, and requires an int be passed - self._controller.rachio.zone.start(self.zone_id, manual_run_time.seconds) + self._controller.start_zone_watering(self.zone_id, manual_run_time.seconds) + # Rachio does not send a zone-status webhook for changes made by the + # same API client, so reflect a successful command immediately. + self._attr_is_on = True + self.schedule_update_ha_state() _LOGGER.debug( "Watering %s on %s for %s", self.name, @@ -380,6 +384,9 @@ def turn_on(self, **kwargs: Any) -> None: def turn_off(self, **kwargs: Any) -> None: """Stop watering all zones.""" self._controller.stop_watering() + # Rachio does not deliver the stop webhook, so keep the state current. + self._attr_is_on = False + self.schedule_update_ha_state() def set_moisture_percent(self, percent) -> None: """Set the zone moisture percent.""" diff --git a/tests/components/rachio/conftest.py b/tests/components/rachio/conftest.py index eec18f11f6fad9..9bc2b5c5ebc728 100644 --- a/tests/components/rachio/conftest.py +++ b/tests/components/rachio/conftest.py @@ -30,7 +30,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_rachio() -> Generator[MagicMock]: +def mock_rachio(request: pytest.FixtureRequest) -> Generator[MagicMock]: """Return a mocked Rachio client.""" with patch( "homeassistant.components.rachio.Rachio", @@ -46,9 +46,21 @@ def mock_rachio() -> Generator[MagicMock]: { "username": "testuser", "id": "test-user-id", - "devices": [], + "devices": getattr(request, "param", []), }, ) + rachio.notification.get_device_webhook.return_value = ({"status": 200}, []) + rachio.notification.get_webhook_event_type.return_value = ( + {"status": 200}, + [], + ) + rachio.notification.add.return_value = ( + {"status": 200}, + {"id": "test-device-webhook-id"}, + ) + rachio.device.current_schedule.return_value = ({"status": 200}, {}) + rachio.device.stop_water.return_value = ({"status": 204}, {}) + rachio.zone.start.return_value = ({"status": 204}, {}) rachio.valve.list_base_stations.return_value = ( {"status": 200}, { diff --git a/tests/components/rachio/test_switch.py b/tests/components/rachio/test_switch.py new file mode 100644 index 00000000000000..bc97676f6985ec --- /dev/null +++ b/tests/components/rachio/test_switch.py @@ -0,0 +1,167 @@ +"""Tests for the Rachio switch platform.""" + +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +CONTROLLER_ID = "controller-id" +ZONE_ID = "zone-id" +ZONE_ENTITY_ID = "switch.test_controller_test_zone" + +MOCK_CONTROLLER = { + "id": CONTROLLER_ID, + "name": "Test Controller", + "serialNumber": "serial-number", + "macAddress": "00:9D:6B:00:00:01", + "model": "GENERATION3", + "zones": [ + { + "id": ZONE_ID, + "name": "Test Zone", + "zoneNumber": 1, + "enabled": True, + } + ], + "scheduleRules": [], + "flexScheduleRules": [], +} + +pytestmark = [ + pytest.mark.parametrize("mock_rachio", [[MOCK_CONTROLLER]], indirect=True), + pytest.mark.parametrize("init_integration", [Platform.SWITCH], indirect=True), + pytest.mark.usefixtures("init_integration"), +] + + +@pytest.mark.parametrize( + "success_status", + [ + pytest.param(200, id="ok"), + pytest.param(204, id="no-content"), + ], +) +async def test_zone_services_update_state( + hass: HomeAssistant, + mock_rachio: MagicMock, + success_status: int, +) -> None: + """Test zone services optimistically update Home Assistant state.""" + mock_rachio.device.stop_water.return_value = ({"status": success_status}, {}) + mock_rachio.zone.start.return_value = ({"status": success_status}, {}) + + assert hass.states.is_state(ZONE_ENTITY_ID, STATE_OFF) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ZONE_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.is_state(ZONE_ENTITY_ID, STATE_ON) + mock_rachio.device.stop_water.assert_called_once_with(CONTROLLER_ID) + mock_rachio.zone.start.assert_called_once_with(ZONE_ID, 600) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ZONE_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.is_state(ZONE_ENTITY_ID, STATE_OFF) + assert mock_rachio.device.stop_water.call_count == 2 + + +@pytest.mark.parametrize( + ("response", "side_effect", "expected_exception"), + [ + pytest.param( + ({"status": 500}, {}), + None, + HomeAssistantError, + id="error-response", + ), + pytest.param(None, RuntimeError, RuntimeError, id="exception"), + ], +) +async def test_zone_start_failure_does_not_update_state( + hass: HomeAssistant, + mock_rachio: MagicMock, + response: tuple[dict[str, int], dict[str, Any]] | None, + side_effect: type[Exception] | None, + expected_exception: type[Exception], +) -> None: + """Test a failed start command does not optimistically update state.""" + mock_rachio.zone.start.return_value = response + mock_rachio.zone.start.side_effect = side_effect + + with pytest.raises(expected_exception): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ZONE_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.is_state(ZONE_ENTITY_ID, STATE_OFF) + + +@pytest.mark.parametrize( + ("response", "side_effect", "expected_exception"), + [ + pytest.param( + ({"status": 500}, {}), + None, + HomeAssistantError, + id="error-response", + ), + pytest.param(None, RuntimeError, RuntimeError, id="exception"), + ], +) +async def test_zone_stop_failure_does_not_update_state( + hass: HomeAssistant, + mock_rachio: MagicMock, + response: tuple[dict[str, int], dict[str, Any]] | None, + side_effect: type[Exception] | None, + expected_exception: type[Exception], +) -> None: + """Test a failed stop command does not optimistically update state.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ZONE_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.is_state(ZONE_ENTITY_ID, STATE_ON) + + mock_rachio.device.stop_water.return_value = response + mock_rachio.device.stop_water.side_effect = side_effect + + with pytest.raises(expected_exception): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ZONE_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.is_state(ZONE_ENTITY_ID, STATE_ON) From 00d7c71f507bb8005feedb78c37b87a48e9577e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Mind=C3=AAllo=20de=20Andrade?= Date: Wed, 2 Sep 2026 11:57:55 -0300 Subject: [PATCH 11/13] Use dhcp discovery to update midea device IP (#180955) --- homeassistant/components/midea/config_flow.py | 28 +++++++ homeassistant/components/midea/manifest.json | 5 ++ homeassistant/generated/dhcp.py | 4 + tests/components/midea/conftest.py | 6 +- tests/components/midea/const.py | 1 + .../midea/snapshots/test_diagnostics.ambr | 2 + tests/components/midea/test_config_flow.py | 83 ++++++++++++++++++- 7 files changed, 126 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/midea/config_flow.py b/homeassistant/components/midea/config_flow.py index 45fb9b06c2bbb9..c2e05d2707f8ea 100644 --- a/homeassistant/components/midea/config_flow.py +++ b/homeassistant/components/midea/config_flow.py @@ -31,7 +31,9 @@ CONF_TYPE, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( CONF_ACCOUNT, @@ -772,3 +774,29 @@ def _show_manually_form( data_schema=schema, errors={"base": error} if error else None, ) + + @override + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery of a known Midea device. + + Only devices already configured (matched via ``registered_devices``) + reach this step. It is used to keep the stored host in sync with the + current IP address of the device. + """ + mac = format_mac(discovery_info.macaddress) + for entry in self._async_current_entries(): + if (entry_mac := entry.data.get(CONF_MAC)) is None or format_mac( + entry_mac + ) != mac: + continue + if entry.data[CONF_IP_ADDRESS] != discovery_info.ip: + self.hass.config_entries.async_update_entry( + entry, + data=entry.data | {CONF_IP_ADDRESS: discovery_info.ip}, + ) + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_abort(reason="already_configured") + + return self.async_abort(reason="no_devices_found") diff --git a/homeassistant/components/midea/manifest.json b/homeassistant/components/midea/manifest.json index e4f525e2aab82c..7a1353ae49d381 100644 --- a/homeassistant/components/midea/manifest.json +++ b/homeassistant/components/midea/manifest.json @@ -3,6 +3,11 @@ "name": "Midea", "codeowners": ["@chemelli74", "@rokam", "@caibinqing"], "config_flow": true, + "dhcp": [ + { + "registered_devices": true + } + ], "documentation": "https://www.home-assistant.io/integrations/midea", "integration_type": "device", "iot_class": "local_polling", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 12352d462bf8c9..bb1289afb01f64 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -651,6 +651,10 @@ "hostname": "lyric-*", "macaddress": "00D02D*", }, + { + "domain": "midea", + "registered_devices": True, + }, { "domain": "mitsubishi_comfort", "registered_devices": True, diff --git a/tests/components/midea/conftest.py b/tests/components/midea/conftest.py index c47b75e3f5d410..6a0ed19f8cbe9e 100644 --- a/tests/components/midea/conftest.py +++ b/tests/components/midea/conftest.py @@ -7,9 +7,9 @@ from midealocal.const import DeviceType import pytest -from homeassistant.components.midea.const import CONF_KEY, CONF_SUBTYPE, DOMAIN +from homeassistant.components.midea.const import CONF_KEY, CONF_SN, CONF_SUBTYPE, DOMAIN from homeassistant.components.midea.device_catalog import MIDEA_DEVICE_NAMES -from homeassistant.const import CONF_NAME, CONF_TOKEN, CONF_TYPE +from homeassistant.const import CONF_MAC, CONF_NAME, CONF_TOKEN, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -179,6 +179,8 @@ def _create(device: DummyDevice) -> MockConfigEntry: **BASE_DATA, CONF_TYPE: device.device_type, CONF_NAME: MIDEA_DEVICE_NAMES[device.device_type], + CONF_MAC: TEST_MAC_ADDRESS, + CONF_SN: TEST_SERIAL_NUMBER, CONF_TOKEN: TEST_TOKEN, CONF_KEY: TEST_KEY, CONF_SUBTYPE: TEST_SUBTYPE, diff --git a/tests/components/midea/const.py b/tests/components/midea/const.py index 1b79090df57f3b..4bc4d1e5cb24ab 100644 --- a/tests/components/midea/const.py +++ b/tests/components/midea/const.py @@ -15,6 +15,7 @@ CONF_TYPE, ) +TEST_HOSTNAME = "net_ac_2233" TEST_DEVICE_ID = 12345678 TEST_IP_ADDRESS = "1.1.1.1" TEST_KEY = "bb" * 16 diff --git a/tests/components/midea/snapshots/test_diagnostics.ambr b/tests/components/midea/snapshots/test_diagnostics.ambr index d3c0fb464e231e..3c8f3eddd7f1d4 100644 --- a/tests/components/midea/snapshots/test_diagnostics.ambr +++ b/tests/components/midea/snapshots/test_diagnostics.ambr @@ -17,10 +17,12 @@ 'device_id': 12345678, 'ip_address': '1.1.1.1', 'key': '**REDACTED**', + 'mac': '**REDACTED**', 'model': 'MSAGBU-09HRFN8', 'name': 'Air Conditioner', 'port': 6444, 'protocol': 3, + 'sn': '**REDACTED**', 'subtype': 0, 'token': '**REDACTED**', 'type': 172, diff --git a/tests/components/midea/test_config_flow.py b/tests/components/midea/test_config_flow.py index b0fa9a5ef3feae..a86af3682b094c 100644 --- a/tests/components/midea/test_config_flow.py +++ b/tests/components/midea/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for the Midea config flow.""" +from collections.abc import Callable from functools import partial from unittest.mock import AsyncMock, MagicMock, patch @@ -22,7 +23,7 @@ DOMAIN, ) from homeassistant.components.midea.device_catalog import MIDEA_DEVICE_NAMES -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import ( CONF_DEVICE, CONF_DEVICE_ID, @@ -33,17 +34,21 @@ CONF_PASSWORD, CONF_PORT, CONF_PROTOCOL, + CONF_SOURCE, CONF_TOKEN, CONF_TYPE, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from .conftest import DummyDevice, default_ac_device from .const import ( BASE_DATA, DISCOVERY_RESULT, EXTENDED_DATA, TEST_DEVICE_ID, + TEST_HOSTNAME, TEST_IP_ADDRESS, TEST_KEY, TEST_MAC_ADDRESS, @@ -2031,3 +2036,79 @@ async def test_auth_method_preset_login_failed(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "auth_method" assert result["errors"] == {"base": "preset_login_failed"} + + +async def test_dhcp_discovery_updates_host( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test DHCP discovery of a known device updates its stored host.""" + config_entry = mock_config_entry(default_ac_device()) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname=TEST_HOSTNAME, + ip="127.0.0.42", + macaddress=TEST_MAC_ADDRESS.replace(":", ""), + ), + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert config_entry.data[CONF_IP_ADDRESS] == "127.0.0.42" + + +async def test_dhcp_discovery_same_host( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test DHCP discovery does nothing when the host is already up to date.""" + config_entry = mock_config_entry(default_ac_device()) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname=TEST_HOSTNAME, + ip=TEST_IP_ADDRESS, + macaddress=TEST_MAC_ADDRESS.replace(":", ""), + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert config_entry.data[CONF_IP_ADDRESS] == TEST_IP_ADDRESS + + +async def test_dhcp_discovery_no_match( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test DHCP discovery aborts when no matching entry is configured.""" + config_entry = mock_config_entry(default_ac_device()) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname=TEST_HOSTNAME, + ip="1.2.3.4", + macaddress="aabbccddeeff", + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + assert config_entry.data[CONF_IP_ADDRESS] == TEST_IP_ADDRESS From edee57d4e2fe64d904018640b3b5ece084adbae9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 2 Sep 2026 17:05:04 +0200 Subject: [PATCH 12/13] Improve error handling in file_upload (#181078) --- .../components/file_upload/__init__.py | 52 ++++---- tests/components/file_upload/test_init.py | 116 +++++++++++++++++- 2 files changed, 145 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/file_upload/__init__.py b/homeassistant/components/file_upload/__init__.py index 067e87f559f779..b535b080767d0b 100644 --- a/homeassistant/components/file_upload/__init__.py +++ b/homeassistant/components/file_upload/__init__.py @@ -176,27 +176,37 @@ def _sync_queue_consumer() -> None: fut: asyncio.Future[None] | None = None try: - fut = hass.async_add_executor_job(_sync_queue_consumer) - megabytes_sending = 0 - while chunk := await file_field_reader.read_chunk(ONE_MEGABYTE): - megabytes_sending += 1 - if megabytes_sending % 5 != 0: - queue.put_nowait((chunk, None)) - continue - - chunk_future = hass.loop.create_future() - queue.put_nowait((chunk, chunk_future)) - await asyncio.wait( - (fut, chunk_future), return_when=asyncio.FIRST_COMPLETED - ) - if fut.done(): - # The executor job failed - break - - queue.put_nowait(None) # terminate queue consumer - finally: - if fut is not None: - await fut + try: + fut = hass.async_add_executor_job(_sync_queue_consumer) + chunks_sent = 0 + while chunk := await file_field_reader.read_chunk(ONE_MEGABYTE): + chunks_sent += 1 + if chunks_sent % 5 != 0: + queue.put_nowait((chunk, None)) + continue + + chunk_future = hass.loop.create_future() + queue.put_nowait((chunk, chunk_future)) + await asyncio.wait( + (fut, chunk_future), return_when=asyncio.FIRST_COMPLETED + ) + if fut.done(): + # The executor job failed + break + finally: + # Always terminate the queue consumer, also if the stream raised or + # the task was cancelled. + queue.put_nowait(None) + if fut is not None: + await fut + except Exception, asyncio.CancelledError: + # Upload failed: the consumer has finished and closed the file (inner + # finally above), so removing the directory now cannot race the writer. + # ignore_errors covers a failure that happened before the dir was created. + await hass.async_add_executor_job( + lambda: shutil.rmtree(file_dir, ignore_errors=True) + ) + raise file_upload_data.files[file_id] = filename diff --git a/tests/components/file_upload/test_init.py b/tests/components/file_upload/test_init.py index 49daf1b3b63ff1..6f940e63fc07a2 100644 --- a/tests/components/file_upload/test_init.py +++ b/tests/components/file_upload/test_init.py @@ -1,15 +1,19 @@ """Test the File Upload integration.""" +import asyncio from contextlib import contextmanager +from io import StringIO from pathlib import Path from random import getrandbits from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch +from aiohttp import BodyPartReader import pytest from homeassistant.components import file_upload -from homeassistant.components.file_upload import DOMAIN +from homeassistant.components.file_upload import DOMAIN, FileUploadView +from homeassistant.components.http import KEY_HASS from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -170,3 +174,111 @@ def write(self, data: bytes) -> None: response = await res.content.read() assert b"Boom" in response + + +async def test_upload_stream_error_releases_lock( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + large_file_io: StringIO, +) -> None: + """Test a mid-upload stream error propagates and releases the upload lock. + + Models a client disconnect: aiohttp raises from read_chunk when the + connection is lost mid-transfer. If the queue consumer's terminating sentinel + is skipped on the error path, awaiting the consumer deadlocks while holding + the upload lock, wedging every later upload; this guards that regression. + """ + assert await async_setup_component(hass, DOMAIN, {}) + client = await hass_client() + + with ( + patch( + # Patch temp dir name to avoid tests fail running in parallel + "homeassistant.components.file_upload.TEMP_DIR_NAME", + file_upload.TEMP_DIR_NAME + f"-{getrandbits(10):03x}", + ), + patch.object( + BodyPartReader, + "read_chunk", + AsyncMock( + side_effect=[b"partial", ConnectionResetError("Connection lost")] + ), + ), + ): + # Bound the request so a reintroduced deadlock fails fast instead of hanging + async with asyncio.timeout(10): + res = await client.post("/api/file_upload", data={"file": large_file_io}) + + assert res.status == 500 + + # The failed upload must not leave a partially written file orphaned on disk + file_upload_data = hass.data[file_upload.DOMAIN] + assert list(file_upload_data.temp_dir.iterdir()) == [] + + # The upload lock must have been released: a subsequent normal upload succeeds + large_file_io.seek(0) + with patch( + "homeassistant.components.file_upload.TEMP_DIR_NAME", + file_upload.TEMP_DIR_NAME + f"-{getrandbits(10):03x}", + ): + async with asyncio.timeout(10): + res = await client.post("/api/file_upload", data={"file": large_file_io}) + + assert res.status == 200 + + +async def test_upload_cancelled_releases_consumer(hass: HomeAssistant) -> None: + """Test cancelling an upload mid-transfer does not deadlock the consumer. + + Driven at the view level because the test HTTP client cannot produce a true + task cancellation mid-request. Without delivering the queue sentinel on the + cancellation path, awaiting the consumer future would hang forever. + """ + assert await async_setup_component(hass, DOMAIN, {}) + view = FileUploadView() + + first_chunk_sent = asyncio.Event() + blocked = asyncio.Event() # never set, so the stream blocks until cancelled + + class _BlockingPart: + """Fake BodyPartReader that blocks after yielding one chunk.""" + + name = "file" + filename = "blocking.bin" + + async def read_chunk(self, size: int) -> bytes: + if first_chunk_sent.is_set(): + await blocked.wait() + return b"" + first_chunk_sent.set() + return b"chunk" + + part = _BlockingPart() + + class _Reader: + async def next(self) -> _BlockingPart: + return part + + class _Request: + app = {KEY_HASS: hass} + + async def multipart(self) -> _Reader: + return _Reader() + + with ( + patch( + "homeassistant.components.file_upload.TEMP_DIR_NAME", + file_upload.TEMP_DIR_NAME + f"-{getrandbits(10):03x}", + ), + patch("homeassistant.components.file_upload.BodyPartReader", _BlockingPart), + ): + task = asyncio.create_task(view._upload_file(_Request())) + await first_chunk_sent.wait() + task.cancel() + # asyncio.wait does not cancel on timeout, so a deadlocked task stays + # pending and the assertion fails fast instead of the run hanging. + _done, pending = await asyncio.wait({task}, timeout=10) + + assert not pending + with pytest.raises(asyncio.CancelledError): + task.result() From 23af6097cebad1c8551795bb49fdc07c5ec492b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B0=BD=EC=A7=84?= Date: Thu, 3 Sep 2026 00:06:06 +0900 Subject: [PATCH 13/13] Fix SMTP attaching valid images as files when content sniffing fails (#176774) Co-authored-by: Claude Fable 5 --- homeassistant/components/smtp/helpers.py | 28 ++++++---- tests/components/smtp/test_notify.py | 68 ++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/smtp/helpers.py b/homeassistant/components/smtp/helpers.py index 59d36976ba3d32..50fce367042fb7 100644 --- a/homeassistant/components/smtp/helpers.py +++ b/homeassistant/components/smtp/helpers.py @@ -137,26 +137,36 @@ def _attach_file( _LOGGER.warning("Attachment %s not found. Skipping", atch_name) return None - attachment: MIMEImage | MIMEApplication + attachment: MIMEImage | MIMEApplication | None = None try: attachment = MIMEImage(file_bytes) except TypeError: + # Not all valid images are recognized from their content, e.g. JPEGs + # written by ffmpeg for camera.snapshot start with a comment marker + # instead of JFIF/Exif, so fall back to guessing from the filename. + # Compressed files (e.g. .svgz) must not be labeled as plain images. + mime_type, encoding = mimetypes.guess_type(atch_name) + maintype, _, subtype = (mime_type or "").partition("/") + if encoding is None and maintype == "image": + attachment = MIMEImage(file_bytes, _subtype=subtype) + + if attachment is None: _LOGGER.warning( - "Attachment %s has an unknown MIME type. Falling back to file", + "Could not determine an image type for attachment %s from its" + " content or filename. Falling back to file", atch_name, ) attachment = MIMEApplication(file_bytes, Name=os.path.basename(atch_name)) attachment["Content-Disposition"] = ( f'attachment; filename="{os.path.basename(atch_name)}"' ) + elif content_id: + attachment.add_header("Content-ID", f"<{content_id}>") else: - if content_id: - attachment.add_header("Content-ID", f"<{content_id}>") - else: - attachment.add_header( - "Content-Disposition", - f"attachment; filename={os.path.basename(atch_name)}", - ) + attachment.add_header( + "Content-Disposition", + f"attachment; filename={os.path.basename(atch_name)}", + ) return attachment diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index eae90eceecbf73..dce109d99e3822 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -1,5 +1,6 @@ """The tests for the notify smtp platform.""" +import gzip from pathlib import Path import re from smtplib import SMTPException, SMTPServerDisconnected @@ -11,6 +12,7 @@ from homeassistant.components import camera, image, media_source from homeassistant.components.notify import ( + ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET, DOMAIN as NOTIFY_DOMAIN, @@ -682,3 +684,69 @@ async def test_deprecated_legacy_notify_action( assert issue_registry.async_get_issue( domain=DOMAIN, issue_id="deprecated_notify_action_home_assistant" ) + + +@pytest.mark.parametrize( + ("file_name", "file_bytes", "expected", "not_expected"), + [ + ( + "doorphone.jpg", + bytes.fromhex("ffd8fffe0010") + + b"Lavc62.28.102\x00" + + bytes.fromhex("ffdb"), + "Content-Type: image/jpeg", + "application/octet-stream", + ), + ( + "diagram.svgz", + gzip.compress(b""), + "application/octet-stream", + "Content-Type: image/", + ), + ], + ids=[ + "Verify a JPEG the stdlib cannot sniff is attached as an image.", + "Verify a compressed image is attached as a file.", + ], +) +@pytest.mark.usefixtures("aiosmtplib") +async def test_legacy_notify_image_attachment( + hass: HomeAssistant, + config_entry: MockConfigEntry, + smtp: MagicMock, + tmp_path: Path, + file_name: str, + file_bytes: bytes, + expected: str, + not_expected: str, +) -> None: + """Test the MIME type images are attached with. + + JPEGs written by ffmpeg for camera.snapshot start with an SOI + COM marker + instead of JFIF/Exif, which MIMEImage does not recognize, so the file name + decides the type. Compressed images stay on the file attachment path. + """ + + image_file = tmp_path / file_name + image_file.write_bytes(file_bytes) + hass.config.allowlist_external_dirs.add(tmp_path) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + NOTIFY_DOMAIN, + "home_assistant", + { + ATTR_MESSAGE: "Test msg", + ATTR_DATA: {"images": [str(image_file)]}, + }, + blocking=True, + ) + + sent_message = smtp.sendmail.call_args[0][2] + assert expected in sent_message + assert not_expected not in sent_message