Keep add-on update entity in progress across post-install refresh (#168756)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Agner
2026-04-27 14:35:28 +01:00
committed by abmantis
co-authored by Claude Opus 4.7
parent db91c0eaee
commit ead2ff214f
2 changed files with 244 additions and 7 deletions
+45 -4
View File
@@ -87,7 +87,18 @@ async def async_setup_entry(
class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
"""Update entity to handle updates for the Supervisor add-ons."""
"""Update entity to handle updates for the Supervisor add-ons.
The ``addon_manager_update`` job emits a ``done=True`` WS event as soon as
Supervisor finishes the container work, a few milliseconds before the
``/store/addons/<slug>/update`` HTTP call returns. If we clear
``_attr_in_progress`` on that event while the coordinator data still
carries the pre-update version, the UI briefly flips back to
"Update available" before ``async_install`` can refresh. ``_update_ongoing``
survives both the WS done event and the base ``UpdateEntity`` reset, so
the installing state remains until the coordinator confirms a new
``installed_version``.
"""
_attr_supported_features = (
UpdateEntityFeature.INSTALL
@@ -95,6 +106,8 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
| UpdateEntityFeature.RELEASE_NOTES
| UpdateEntityFeature.PROGRESS
)
_update_ongoing: bool = False
_version_before_update: str | None = None
@property
def _addon_data(self) -> dict:
@@ -121,6 +134,13 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
"""Version installed and in use."""
return self._addon_data[ATTR_VERSION]
@property
def in_progress(self) -> bool | None:
"""Return combined progress from the update job and refresh phase."""
if self._update_ongoing:
return True
return self._attr_in_progress
@property
def entity_picture(self) -> str | None:
"""Return the icon of the add-on if any."""
@@ -154,13 +174,34 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
**kwargs: Any,
) -> None:
"""Install an update."""
self._version_before_update = self.installed_version
self._update_ongoing = True
self._attr_in_progress = True
self.async_write_ha_state()
await update_addon(
self.hass, self._addon_slug, backup, self.title, self.installed_version
)
try:
await update_addon(
self.hass, self._addon_slug, backup, self.title, self.installed_version
)
except HomeAssistantError:
self._update_ongoing = False
self._version_before_update = None
self._attr_in_progress = False
self._attr_update_percentage = None
self.async_write_ha_state()
raise
await self.coordinator.async_refresh()
@callback
def _handle_coordinator_update(self) -> None:
"""Clear the ongoing flag once the installed version has changed."""
if (
self._update_ongoing
and self.installed_version != self._version_before_update
):
self._update_ongoing = False
self._version_before_update = None
super()._handle_coordinator_update()
@callback
def _update_job_changed(self, job: Job) -> None:
"""Process update for this entity's update job."""
+199 -3
View File
@@ -1101,9 +1101,11 @@ async def test_update_addon_sets_progress_immediately(
async def test_update_addon_resets_progress_on_error(
hass: HomeAssistant, supervisor_client: AsyncMock
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
) -> None:
"""Test addon update resets in_progress to False when update fails."""
"""Test addon update resets in_progress and update_percentage on failure."""
config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN)
config_entry.add_to_hass(hass)
@@ -1118,11 +1120,48 @@ async def test_update_addon_resets_progress_on_error(
state = hass.states.get("update.test_update")
assert state.attributes.get("in_progress") is False
assert state.attributes.get("update_percentage") is None
ws = await hass_ws_client(hass)
job_uuid = uuid4().hex
async def fake_update_addon_error(
_hass: HomeAssistant,
_addon: str,
_backup: bool,
_addon_name: str | None,
_installed_version: str | None,
) -> None:
"""Report some progress, then fail - as a mid-pull network error would."""
await ws.send_json(
{
"id": 1,
"type": "supervisor/event",
"data": {
"event": "job",
"data": {
"uuid": job_uuid,
"created": "2025-09-29T00:00:00.000000+00:00",
"name": "addon_manager_update",
"reference": "test",
"progress": 42,
"done": False,
"stage": None,
"extra": {"total": 1234567890},
"errors": [],
},
},
}
)
msg = await ws.receive_json()
assert msg["success"]
await hass.async_block_till_done()
raise HomeAssistantError
with (
patch(
"homeassistant.components.hassio.update.update_addon",
side_effect=HomeAssistantError,
side_effect=fake_update_addon_error,
),
pytest.raises(HomeAssistantError),
):
@@ -1137,6 +1176,163 @@ async def test_update_addon_resets_progress_on_error(
assert state.attributes.get("in_progress") is False, (
"in_progress should be reset to False after error"
)
assert state.attributes.get("update_percentage") is None, (
"update_percentage should be reset to None after error"
)
def _bump_addon_to(
addons_list: AsyncMock,
addon_installed: AsyncMock,
version: str,
version_latest: str,
) -> None:
"""Rewrite the addon fixtures to report a post-update version."""
current = addons_list.return_value
addons_list.return_value = [
replace(
current[0],
version=version,
version_latest=version_latest,
update_available=version != version_latest,
),
*current[1:],
]
def _updated_info(slug: str):
addon = Mock(
spec=InstalledAddonComplete,
to_dict=addon_installed.return_value.to_dict,
**addon_installed.return_value.to_dict(),
)
addon.name = "test"
addon.slug = "test"
addon.version = version
addon.version_latest = version_latest
addon.update_available = version != version_latest
addon.state = AddonState.STARTED
addon.url = "https://github.com/home-assistant/addons/test"
addon.auto_update = True
return addon
addon_installed.side_effect = _updated_info
async def test_update_addon_stays_in_progress_until_refresh(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
update_addon: AsyncMock,
addon_installed: AsyncMock,
addons_list: AsyncMock,
) -> None:
"""Test addon update entity stays in progress until coordinator refresh.
Supervisor emits the ``addon_manager_update`` job ``done=True`` WS event a
few milliseconds before ``/store/addons/<slug>/update`` returns. Without
the ``_update_ongoing`` guard, ``_attr_in_progress`` is cleared while the
coordinator still holds the pre-update version and the UI briefly flips
back to "Update available".
"""
config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN)
config_entry.add_to_hass(hass)
with patch.dict(os.environ, MOCK_ENVIRON):
assert await async_setup_component(
hass,
"hassio",
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
)
await hass.async_block_till_done()
entity_id = "update.test_update"
assert hass.states.get(entity_id).state == "on"
ws = await hass_ws_client(hass)
job_uuid = uuid4().hex
in_progress_after_done: list[bool | None] = []
async def fake_update_addon(slug: str, _options: StoreAddonUpdate) -> None:
"""Mimic Supervisor: fire done=True on WS, then return HTTP response."""
await ws.send_json(
{
"id": 1,
"type": "supervisor/event",
"data": {
"event": "job",
"data": {
"uuid": job_uuid,
"created": "2025-09-29T00:00:00.000000+00:00",
"name": "addon_manager_update",
"reference": "test",
"progress": 100,
"done": True,
"stage": None,
"extra": {"total": 1234567890},
"errors": [],
},
},
}
)
msg = await ws.receive_json()
assert msg["success"]
await hass.async_block_till_done()
in_progress_after_done.append(
hass.states.get(entity_id).attributes.get("in_progress")
)
_bump_addon_to(addons_list, addon_installed, "2.0.1", "2.0.1")
update_addon.side_effect = fake_update_addon
await hass.services.async_call(
"update", "install", {"entity_id": entity_id}, blocking=True
)
# The done=True WS event fired mid-install must not drop in_progress; the
# coordinator data at that instant still carries the pre-update version.
assert in_progress_after_done == [True]
state = hass.states.get(entity_id)
assert state.attributes.get("in_progress") is False
assert state.state == "off"
async def test_update_addon_completes_on_any_version_change(
hass: HomeAssistant,
update_addon: AsyncMock,
addon_installed: AsyncMock,
addons_list: AsyncMock,
) -> None:
"""Test completion when installed version changes from the pre-install one.
If a newer upstream release appears between install start and the refresh,
``installed_version`` will not equal ``latest_version`` but will differ
from the pre-install version. The ongoing flag must still clear.
"""
config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN)
config_entry.add_to_hass(hass)
with patch.dict(os.environ, MOCK_ENVIRON):
assert await async_setup_component(
hass,
"hassio",
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
)
await hass.async_block_till_done()
entity_id = "update.test_update"
async def fake_update_addon(slug: str, _options: StoreAddonUpdate) -> None:
_bump_addon_to(addons_list, addon_installed, "2.0.1", "2.0.2")
update_addon.side_effect = fake_update_addon
await hass.services.async_call(
"update", "install", {"entity_id": entity_id}, blocking=True
)
state = hass.states.get(entity_id)
assert state.attributes.get("in_progress") is False
assert state.state == "on"
async def test_update_supervisor(