From e647757b8bbf7f39a999a80806c3f503cec658ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Jul 2026 05:21:31 -1000 Subject: [PATCH] Skip global ESPHome update lock when dashboard has a build queue (#176162) --- .../components/esphome/coordinator.py | 17 ++-- homeassistant/components/esphome/update.py | 36 ++++--- tests/components/esphome/test_update.py | 93 +++++++++++++++++++ 3 files changed, 124 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/esphome/coordinator.py b/homeassistant/components/esphome/coordinator.py index dfe4741a0d6e..ec4e2ad9cc43 100644 --- a/homeassistant/components/esphome/coordinator.py +++ b/homeassistant/components/esphome/coordinator.py @@ -14,6 +14,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator _LOGGER = logging.getLogger(__name__) MIN_VERSION_SUPPORTS_UPDATE = AwesomeVersion("2023.1.0") +MIN_VERSION_SUPPORTS_BUILD_QUEUE = AwesomeVersion("2026.6.0") REFRESH_INTERVAL = timedelta(minutes=5) @@ -34,6 +35,7 @@ class ESPHomeDashboardCoordinator(DataUpdateCoordinator[dict[str, ConfiguredDevi self.url = url self.api = ESPHomeDashboardAPI(url, async_get_clientsession(hass)) self.supports_update: bool | None = None + self.supports_build_queue = False @override async def _async_update_data(self) -> dict[str, ConfiguredDevice]: @@ -41,13 +43,14 @@ class ESPHomeDashboardCoordinator(DataUpdateCoordinator[dict[str, ConfiguredDevi devices = await self.api.get_devices() configured_devices = devices["configured"] - if ( - self.supports_update is None - and configured_devices - and (current_version := configured_devices[0].get("current_version")) + if configured_devices and ( + current_version := configured_devices[0].get("current_version") ): - self.supports_update = ( - AwesomeVersion(current_version) > MIN_VERSION_SUPPORTS_UPDATE - ) + version = AwesomeVersion(current_version) + if self.supports_update is None: + self.supports_update = version > MIN_VERSION_SUPPORTS_UPDATE + # The dashboard has its own build queue since 2026.6.0 + # and can accept multiple compile requests at once + self.supports_build_queue = version >= MIN_VERSION_SUPPORTS_BUILD_QUEUE return {dev["name"]: dev for dev in configured_devices} diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index eff6c6ab4b73..2b6f49272c04 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -233,21 +233,27 @@ class ESPHomeDashboardUpdateEntity( # Ensure only one OTA per device at a time async with self._install_lock: - # Ensure only one compile at a time for ALL devices - async with self.hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()): - coordinator = self.coordinator - api = coordinator.api - device = coordinator.data.get(self._device_info.name) - assert device is not None - configuration = device["configuration"] - if not await api.compile(configuration): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="error_compiling", - translation_placeholders={ - "configuration": configuration, - }, - ) + coordinator = self.coordinator + api = coordinator.api + device = coordinator.data.get(self._device_info.name) + assert device is not None + configuration = device["configuration"] + if coordinator.supports_build_queue: + # The dashboard has its own build queue + # and can handle concurrent compile requests + compiled = await api.compile(configuration) + else: + # Ensure only one compile at a time for ALL devices + async with self.hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()): + compiled = await api.compile(configuration) + if not compiled: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="error_compiling", + translation_placeholders={ + "configuration": configuration, + }, + ) # If the device uses deep sleep, there's a small chance it goes # to sleep right after the dashboard connects but before the OTA diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index 1bdb4b4ac637..c29606fdff1c 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -10,6 +10,7 @@ from awesomeversion.exceptions import AwesomeVersionCompareException import pytest from homeassistant.components.esphome.dashboard import async_get_dashboard +from homeassistant.components.esphome.update import KEY_UPDATE_LOCK from homeassistant.components.homeassistant import ( DOMAIN as HOMEASSISTANT_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -811,6 +812,98 @@ async def test_attempt_to_update_twice( await update_task +async def test_update_dashboard_with_build_queue_skips_global_lock( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_dashboard: dict[str, Any], +) -> None: + """Test the global compile lock is skipped when the dashboard has a build queue.""" + mock_dashboard["configured"] = [ + { + "name": "test", + "current_version": "2026.6.0", + "configuration": "test.yaml", + } + ] + await async_get_dashboard(hass).async_refresh() + await mock_esphome_device(mock_client=mock_client) + await hass.async_block_till_done() + + # Hold the global compile lock; the install must not need it + lock = hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()) + await lock.acquire() + with ( + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + return_value=True, + ) as mock_compile, + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + return_value=True, + ), + ): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, + blocking=True, + ) + lock.release() + + assert len(mock_compile.mock_calls) == 1 + assert mock_compile.mock_calls[0][1][0] == "test.yaml" + + +async def test_update_dashboard_without_build_queue_waits_for_global_lock( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_dashboard: dict[str, Any], +) -> None: + """Test the global compile lock still serializes installs on older dashboards.""" + mock_dashboard["configured"] = [ + { + "name": "test", + "current_version": "2026.5.0", + "configuration": "test.yaml", + } + ] + await async_get_dashboard(hass).async_refresh() + await mock_esphome_device(mock_client=mock_client) + await hass.async_block_till_done() + + lock = hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()) + await lock.acquire() + with ( + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + return_value=True, + ) as mock_compile, + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + return_value=True, + ), + ): + update_task = hass.async_create_task( + hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, + blocking=True, + ) + ) + for _ in range(5): + await asyncio.sleep(0) + # The compile must be blocked on the global lock + assert len(mock_compile.mock_calls) == 0 + lock.release() + await update_task + + assert len(mock_compile.mock_calls) == 1 + assert mock_compile.mock_calls[0][1][0] == "test.yaml" + + async def test_update_deep_sleep_already_online( hass: HomeAssistant, mock_client: APIClient,