Skip global ESPHome update lock when dashboard has a build queue (#176162)

This commit is contained in:
J. Nick Koston
2026-07-10 20:04:24 +00:00
committed by Franck Nijhof
parent 64793495e1
commit e647757b8b
3 changed files with 124 additions and 22 deletions
@@ -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}
+21 -15
View File
@@ -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
+93
View File
@@ -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,