From c277f3cad649f0e0984865cdeb1a8848d83d6aa6 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 4 Sep 2025 13:19:31 +0200 Subject: [PATCH] Store and persist OS upgrade map to fix update path evaluation (#6152) * Store and persist OS upgrade map to fix update path evaluation The existing logic calculated OS upgrade paths inline during fetch_data, which will not get reevaluted when the current OS is unsupported (JobCondition.OS_SUPPORTED). E.g. after updating from 11.4 to 11.5, the system wouldn't offer the next available update (15.2) because the upgrade path calculation relied on fresh data from the blocked fetch operation. Changes: - Add ATTR_HASSOS_UPGRADE constant and schema validation - Store hassos-upgrade map from version JSON in updater data - Refactor version_hassos property to use stored upgrade map instead of inline calculation during fetch_data - Maintain upgrade path logic: upgrade within major version first, then jump to next major version when at the latest in current major - Add type safety checks for version.major access This ensures upgrade paths work correctly even when update data refresh is blocked due to unsupported OS versions, fixing the scenario where HAOS 11.5 wouldn't show 15.2 as the next available update. * Update supervisor/updater.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Address mypy issue * Fix pytest --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- supervisor/const.py | 1 + supervisor/updater.py | 51 +++++++++++++++++++++++++++++++----------- supervisor/validate.py | 4 ++++ tests/api/test_root.py | 2 +- 4 files changed, 44 insertions(+), 14 deletions(-) diff --git a/supervisor/const.py b/supervisor/const.py index 2dd722c5c..ab8f0ccfa 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -202,6 +202,7 @@ ATTR_HASSIO_API = "hassio_api" ATTR_HASSIO_ROLE = "hassio_role" ATTR_HASSOS = "hassos" ATTR_HASSOS_UNRESTRICTED = "hassos_unrestricted" +ATTR_HASSOS_UPGRADE = "hassos_upgrade" ATTR_HEALTHY = "healthy" ATTR_HEARTBEAT_LED = "heartbeat_led" ATTR_HOMEASSISTANT = "homeassistant" diff --git a/supervisor/updater.py b/supervisor/updater.py index 2b004c8a2..32932a7df 100644 --- a/supervisor/updater.py +++ b/supervisor/updater.py @@ -17,8 +17,8 @@ from .const import ( ATTR_CHANNEL, ATTR_CLI, ATTR_DNS, - ATTR_HASSOS, ATTR_HASSOS_UNRESTRICTED, + ATTR_HASSOS_UPGRADE, ATTR_HOMEASSISTANT, ATTR_IMAGE, ATTR_MULTICAST, @@ -93,13 +93,46 @@ class Updater(FileConfiguration, CoreSysAttributes): @property def version_hassos(self) -> AwesomeVersion | None: """Return latest version of HassOS.""" - return self._data.get(ATTR_HASSOS) + upgrade_map = self.upgrade_map_hassos + unrestricted = self.version_hassos_unrestricted + + # If no upgrade map exists, fall back to unrestricted version + if not upgrade_map: + return unrestricted + + # If we have no unrestricted version or no current OS version, return unrestricted + if ( + not unrestricted + or not self.sys_os.version + or self.sys_os.version.major is None + ): + return unrestricted + + current_major = str(self.sys_os.version.major) + # Check if there's an upgrade path for current major version + if current_major in upgrade_map: + last_in_major = AwesomeVersion(upgrade_map[current_major]) + # If we're not at the last version in our major, upgrade to that first + if self.sys_os.version != last_in_major: + return last_in_major + # If we are at the last version in our major, check for next major + next_major = str(int(self.sys_os.version.major) + 1) + if next_major in upgrade_map: + return AwesomeVersion(upgrade_map[next_major]) + + # Fall back to unrestricted version + return unrestricted @property def version_hassos_unrestricted(self) -> AwesomeVersion | None: """Return latest version of HassOS ignoring upgrade restrictions.""" return self._data.get(ATTR_HASSOS_UNRESTRICTED) + @property + def upgrade_map_hassos(self) -> dict[str, str] | None: + """Return HassOS upgrade map.""" + return self._data.get(ATTR_HASSOS_UPGRADE) + @property def version_cli(self) -> AwesomeVersion | None: """Return latest version of CLI.""" @@ -291,18 +324,10 @@ class Updater(FileConfiguration, CoreSysAttributes): if self.sys_os.board: self._data[ATTR_OTA] = data["ota"] if version := data["hassos"].get(self.sys_os.board): - self._data[ATTR_HASSOS_UNRESTRICTED] = version + self._data[ATTR_HASSOS_UNRESTRICTED] = AwesomeVersion(version) + # Store the upgrade map for persistent access + self._data[ATTR_HASSOS_UPGRADE] = data.get("hassos-upgrade", {}) events.append("os") - upgrade_map = data.get("hassos-upgrade", {}) - if last_in_major := upgrade_map.get(str(self.sys_os.version.major)): - if self.sys_os.version != AwesomeVersion(last_in_major): - version = last_in_major - elif last_in_next_major := upgrade_map.get( - str(int(self.sys_os.version.major) + 1) - ): - version = last_in_next_major - - self._data[ATTR_HASSOS] = AwesomeVersion(version) else: _LOGGER.warning( "Board '%s' not found in version file. No OS updates.", diff --git a/supervisor/validate.py b/supervisor/validate.py index e435ab074..8a20ff9b0 100644 --- a/supervisor/validate.py +++ b/supervisor/validate.py @@ -24,6 +24,7 @@ from .const import ( ATTR_FORCE_SECURITY, ATTR_HASSOS, ATTR_HASSOS_UNRESTRICTED, + ATTR_HASSOS_UPGRADE, ATTR_HOMEASSISTANT, ATTR_ID, ATTR_IMAGE, @@ -129,6 +130,9 @@ SCHEMA_UPDATER_CONFIG = vol.Schema( vol.Optional(ATTR_SUPERVISOR): version_tag, vol.Optional(ATTR_HASSOS): version_tag, vol.Optional(ATTR_HASSOS_UNRESTRICTED): version_tag, + vol.Optional(ATTR_HASSOS_UPGRADE): vol.Schema( + {vol.Extra: version_tag}, extra=vol.ALLOW_EXTRA + ), vol.Optional(ATTR_CLI): version_tag, vol.Optional(ATTR_DNS): version_tag, vol.Optional(ATTR_AUDIO): version_tag, diff --git a/tests/api/test_root.py b/tests/api/test_root.py index 9d233ace0..3cc93e3f2 100644 --- a/tests/api/test_root.py +++ b/tests/api/test_root.py @@ -48,7 +48,7 @@ async def test_api_available_updates( "version_latest": "9.2.1", } - coresys.updater._data["hassos"] = "321" + coresys.updater._data["hassos_unrestricted"] = "321" coresys.os._version = "123" updates = await available_updates() assert len(updates) == 2