diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index b5549c1b5e4..8a69f553025 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -51,6 +51,7 @@ from .auth import async_setup_auth_view from .const import ( ATTR_ADDON, ATTR_ADDONS, + ATTR_CHANGELOG, ATTR_DISCOVERY, ATTR_FOLDERS, ATTR_HOMEASSISTANT, @@ -63,6 +64,9 @@ from .const import ( ATTR_URL, ATTR_VERSION, DATA_KEY_ADDONS, + DATA_KEY_CORE, + DATA_KEY_OS, + DATA_KEY_SUPERVISOR, DOMAIN, SupervisorEntityModel, ) @@ -77,7 +81,7 @@ _LOGGER = logging.getLogger(__name__) STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.UPDATE] CONF_FRONTEND_REPO = "development_repo" @@ -93,6 +97,7 @@ DATA_STORE = "hassio_store" DATA_INFO = "hassio_info" DATA_OS_INFO = "hassio_os_info" DATA_SUPERVISOR_INFO = "hassio_supervisor_info" +DATA_ADDONS_CHANGELOGS = "hassio_addons_changelogs" DATA_ADDONS_STATS = "hassio_addons_stats" HASSIO_UPDATE_INTERVAL = timedelta(minutes=5) @@ -239,14 +244,22 @@ async def async_uninstall_addon(hass: HomeAssistant, slug: str) -> dict: @bind_hass @api_data -async def async_update_addon(hass: HomeAssistant, slug: str) -> dict: +async def async_update_addon( + hass: HomeAssistant, + slug: str, + backup: bool = False, +) -> dict: """Update add-on. The caller of the function should handle HassioAPIError. """ hassio = hass.data[DOMAIN] command = f"/addons/{slug}/update" - return await hassio.send_command(command, timeout=None) + return await hassio.send_command( + command, + payload={"backup": backup}, + timeout=None, + ) @bind_hass @@ -323,6 +336,52 @@ async def async_create_backup( return await hassio.send_command(command, payload=payload, timeout=None) +@bind_hass +@api_data +async def async_update_os(hass: HomeAssistant, version: str | None = None) -> dict: + """Update Home Assistant Operating System. + + The caller of the function should handle HassioAPIError. + """ + hassio = hass.data[DOMAIN] + command = "/os/update" + return await hassio.send_command( + command, + payload={"version": version}, + timeout=None, + ) + + +@bind_hass +@api_data +async def async_update_supervisor(hass: HomeAssistant) -> dict: + """Update Home Assistant Supervisor. + + The caller of the function should handle HassioAPIError. + """ + hassio = hass.data[DOMAIN] + command = "/supervisor/update" + return await hassio.send_command(command, timeout=None) + + +@bind_hass +@api_data +async def async_update_core( + hass: HomeAssistant, version: str | None = None, backup: bool = False +) -> dict: + """Update Home Assistant Core. + + The caller of the function should handle HassioAPIError. + """ + hassio = hass.data[DOMAIN] + command = "/core/update" + return await hassio.send_command( + command, + payload={"version": version, "backup": backup}, + timeout=None, + ) + + @callback @bind_hass def get_info(hass): @@ -373,6 +432,16 @@ def get_addons_stats(hass): return hass.data.get(DATA_ADDONS_STATS) +@callback +@bind_hass +def get_addons_changelogs(hass): + """Return Addons changelogs. + + Async friendly. + """ + return hass.data.get(DATA_ADDONS_CHANGELOGS) + + @callback @bind_hass def get_os_info(hass): @@ -533,6 +602,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: stats = await hassio.get_addon_stats(slug) return (slug, stats) + async def update_addon_changelog(slug): + """Return the changelog for an add-on.""" + changelog = await hassio.get_addon_changelog(slug) + return (slug, changelog) + async def update_info_data(now): """Update last available supervisor information.""" @@ -562,6 +636,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: *[update_addon_stats(addon[ATTR_SLUG]) for addon in addons] ) hass.data[DATA_ADDONS_STATS] = dict(stats_data) + hass.data[DATA_ADDONS_CHANGELOGS] = dict( + await asyncio.gather( + *[update_addon_changelog(addon[ATTR_SLUG]) for addon in addons] + ) + ) if ADDONS_COORDINATOR in hass.data: await hass.data[ADDONS_COORDINATOR].async_refresh() @@ -699,6 +778,42 @@ def async_register_os_in_dev_reg( dev_reg.async_get_or_create(config_entry_id=entry_id, **params) +@callback +def async_register_core_in_dev_reg( + entry_id: str, + dev_reg: DeviceRegistry, + core_dict: dict[str, Any], +) -> None: + """Register OS in the device registry.""" + params = DeviceInfo( + identifiers={(DOMAIN, "core")}, + manufacturer="Home Assistant", + model=SupervisorEntityModel.CORE, + sw_version=core_dict[ATTR_VERSION], + name="Home Assistant Core", + entry_type=DeviceEntryType.SERVICE, + ) + dev_reg.async_get_or_create(config_entry_id=entry_id, **params) + + +@callback +def async_register_supervisor_in_dev_reg( + entry_id: str, + dev_reg: DeviceRegistry, + supervisor_dict: dict[str, Any], +) -> None: + """Register OS in the device registry.""" + params = DeviceInfo( + identifiers={(DOMAIN, "supervisor")}, + manufacturer="Home Assistant", + model=SupervisorEntityModel.SUPERVIOSR, + sw_version=supervisor_dict[ATTR_VERSION], + name="Home Assistant Supervisor", + entry_type=DeviceEntryType.SERVICE, + ) + dev_reg.async_get_or_create(config_entry_id=entry_id, **params) + + @callback def async_remove_addons_from_dev_reg(dev_reg: DeviceRegistry, addons: set[str]) -> None: """Remove addons from the device registry.""" @@ -720,6 +835,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): name=DOMAIN, update_method=self._async_update_data, ) + self.hassio: HassIO = hass.data[DOMAIN] self.data = {} self.entry_id = config_entry.entry_id self.dev_reg = dev_reg @@ -730,6 +846,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): new_data = {} supervisor_info = get_supervisor_info(self.hass) addons_stats = get_addons_stats(self.hass) + addons_changelogs = get_addons_changelogs(self.hass) store_data = get_store(self.hass) repositories = { @@ -741,6 +858,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): addon[ATTR_SLUG]: { **addon, **((addons_stats or {}).get(addon[ATTR_SLUG], {})), + ATTR_CHANGELOG: (addons_changelogs or {}).get(addon[ATTR_SLUG]), ATTR_REPOSITORY: repositories.get( addon.get(ATTR_REPOSITORY), addon.get(ATTR_REPOSITORY, "") ), @@ -748,16 +866,25 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): for addon in supervisor_info.get("addons", []) } if self.is_hass_os: - new_data["os"] = get_os_info(self.hass) + new_data[DATA_KEY_OS] = get_os_info(self.hass) + + new_data[DATA_KEY_CORE] = get_core_info(self.hass) + new_data[DATA_KEY_SUPERVISOR] = supervisor_info # If this is the initial refresh, register all addons and return the dict if not self.data: async_register_addons_in_dev_reg( self.entry_id, self.dev_reg, new_data[DATA_KEY_ADDONS].values() ) + async_register_core_in_dev_reg( + self.entry_id, self.dev_reg, new_data[DATA_KEY_CORE] + ) + async_register_supervisor_in_dev_reg( + self.entry_id, self.dev_reg, new_data[DATA_KEY_SUPERVISOR] + ) if self.is_hass_os: async_register_os_in_dev_reg( - self.entry_id, self.dev_reg, new_data["os"] + self.entry_id, self.dev_reg, new_data[DATA_KEY_OS] ) # Remove add-ons that are no longer installed from device registry @@ -782,3 +909,8 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): return {} return new_data + + async def force_info_update_supervisor(self) -> None: + """Force update of the supervisor info.""" + self.hass.data[DATA_SUPERVISOR_INFO] = await self.hassio.get_supervisor_info() + await self.async_refresh() diff --git a/homeassistant/components/hassio/binary_sensor.py b/homeassistant/components/hassio/binary_sensor.py index b5525fe9ce4..c2bcd5eaf68 100644 --- a/homeassistant/components/hassio/binary_sensor.py +++ b/homeassistant/components/hassio/binary_sensor.py @@ -32,6 +32,7 @@ class HassioBinarySensorEntityDescription(BinarySensorEntityDescription): COMMON_ENTITY_DESCRIPTIONS = ( HassioBinarySensorEntityDescription( + # Deprecated, scheduled to be removed in 2022.6 device_class=BinarySensorDeviceClass.UPDATE, entity_registry_enabled_default=False, key=ATTR_UPDATE_AVAILABLE, diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 7cdc87708ae..7f62748b835 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -43,6 +43,7 @@ ATTR_VERSION = "version" ATTR_VERSION_LATEST = "version_latest" ATTR_UPDATE_AVAILABLE = "update_available" ATTR_CPU_PERCENT = "cpu_percent" +ATTR_CHANGELOG = "changelog" ATTR_MEMORY_PERCENT = "memory_percent" ATTR_SLUG = "slug" ATTR_STATE = "state" @@ -53,6 +54,8 @@ ATTR_REPOSITORY = "repository" DATA_KEY_ADDONS = "addons" DATA_KEY_OS = "os" +DATA_KEY_SUPERVISOR = "supervisor" +DATA_KEY_CORE = "core" class SupervisorEntityModel(str, Enum): @@ -60,3 +63,5 @@ class SupervisorEntityModel(str, Enum): ADDON = "Home Assistant Add-on" OS = "Home Assistant Operating System" + CORE = "Home Assistant Core" + SUPERVIOSR = "Home Assistant Supervisor" diff --git a/homeassistant/components/hassio/entity.py b/homeassistant/components/hassio/entity.py index 9f727269fed..fb9f70f1417 100644 --- a/homeassistant/components/hassio/entity.py +++ b/homeassistant/components/hassio/entity.py @@ -8,7 +8,13 @@ from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import DOMAIN, HassioDataUpdateCoordinator -from .const import ATTR_SLUG, DATA_KEY_ADDONS, DATA_KEY_OS +from .const import ( + ATTR_SLUG, + DATA_KEY_ADDONS, + DATA_KEY_CORE, + DATA_KEY_OS, + DATA_KEY_SUPERVISOR, +) class HassioAddonEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): @@ -33,8 +39,9 @@ class HassioAddonEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): """Return True if entity is available.""" return ( super().available + and DATA_KEY_ADDONS in self.coordinator.data and self.entity_description.key - in self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug] + in self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {}) ) @@ -58,5 +65,57 @@ class HassioOSEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): """Return True if entity is available.""" return ( super().available + and DATA_KEY_OS in self.coordinator.data and self.entity_description.key in self.coordinator.data[DATA_KEY_OS] ) + + +class HassioSupervisorEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): + """Base Entity for Supervisor.""" + + def __init__( + self, + coordinator: HassioDataUpdateCoordinator, + entity_description: EntityDescription, + ) -> None: + """Initialize base entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_name = f"Home Assistant Supervisor: {entity_description.name}" + self._attr_unique_id = f"home_assistant_supervisor_{entity_description.key}" + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, "supervisor")}) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return ( + super().available + and DATA_KEY_OS in self.coordinator.data + and self.entity_description.key + in self.coordinator.data[DATA_KEY_SUPERVISOR] + ) + + +class HassioCoreEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): + """Base Entity for Core.""" + + def __init__( + self, + coordinator: HassioDataUpdateCoordinator, + entity_description: EntityDescription, + ) -> None: + """Initialize base entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_name = f"Home Assistant Core: {entity_description.name}" + self._attr_unique_id = f"home_assistant_core_{entity_description.key}" + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, "core")}) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return ( + super().available + and DATA_KEY_CORE in self.coordinator.data + and self.entity_description.key in self.coordinator.data[DATA_KEY_CORE] + ) diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 4a0312bcecb..66395b49400 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -127,6 +127,15 @@ class HassIO: """ return self.send_command(f"/addons/{addon}/stats", method="get") + def get_addon_changelog(self, addon): + """Return changelog for an Add-on. + + This method returns a coroutine. + """ + return self.send_command( + f"/addons/{addon}/changelog", method="get", return_text=True + ) + @api_data def get_store(self): """Return data from the store. @@ -212,7 +221,14 @@ class HassIO: "/supervisor/options", payload={"diagnostics": diagnostics} ) - async def send_command(self, command, method="post", payload=None, timeout=10): + async def send_command( + self, + command, + method="post", + payload=None, + timeout=10, + return_text=False, + ): """Send API command to Hass.io. This method is a coroutine. @@ -230,8 +246,10 @@ class HassIO: _LOGGER.error("%s return code %d", command, request.status) raise HassioAPIError() - answer = await request.json() - return answer + if return_text: + return await request.text(encoding="utf-8") + + return await request.json() except asyncio.TimeoutError: _LOGGER.error("Timeout on %s request", command) diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py new file mode 100644 index 00000000000..71f97c62b96 --- /dev/null +++ b/homeassistant/components/hassio/update.py @@ -0,0 +1,276 @@ +"""Update platform for Supervisor.""" +from __future__ import annotations + +from typing import Any + +from awesomeversion import AwesomeVersion, AwesomeVersionStrategy + +from homeassistant.components.update import ( + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ICON, ATTR_NAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ( + ADDONS_COORDINATOR, + async_update_addon, + async_update_core, + async_update_os, + async_update_supervisor, +) +from .const import ( + ATTR_CHANGELOG, + ATTR_VERSION, + ATTR_VERSION_LATEST, + DATA_KEY_ADDONS, + DATA_KEY_CORE, + DATA_KEY_OS, + DATA_KEY_SUPERVISOR, +) +from .entity import ( + HassioAddonEntity, + HassioCoreEntity, + HassioOSEntity, + HassioSupervisorEntity, +) +from .handler import HassioAPIError + +ENTITY_DESCRIPTION = UpdateEntityDescription( + name="Update", + key=ATTR_VERSION_LATEST, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Supervisor update based on a config entry.""" + coordinator = hass.data[ADDONS_COORDINATOR] + + entities = [ + SupervisorSupervisorUpdateEntity( + coordinator=coordinator, + entity_description=ENTITY_DESCRIPTION, + ), + SupervisorCoreUpdateEntity( + coordinator=coordinator, + entity_description=ENTITY_DESCRIPTION, + ), + ] + + for addon in coordinator.data[DATA_KEY_ADDONS].values(): + entities.append( + SupervisorAddonUpdateEntity( + addon=addon, + coordinator=coordinator, + entity_description=ENTITY_DESCRIPTION, + ) + ) + + if coordinator.is_hass_os: + entities.append( + SupervisorOSUpdateEntity( + coordinator=coordinator, + entity_description=ENTITY_DESCRIPTION, + ) + ) + + async_add_entities(entities) + + +class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity): + """Update entity to handle updates for the Supervisor add-ons.""" + + _attr_supported_features = UpdateEntityFeature.INSTALL | UpdateEntityFeature.BACKUP + + @property + def title(self) -> str | None: + """Return the title of the update.""" + return self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug][ATTR_NAME] + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + return self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug][ + ATTR_VERSION_LATEST + ] + + @property + def current_version(self) -> str | None: + """Version currently in use.""" + return self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug][ATTR_VERSION] + + @property + def release_summary(self) -> str | None: + """Release summary for the add-on.""" + return self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug][ATTR_CHANGELOG] + + @property + def entity_picture(self) -> str | None: + """Return the icon of the add-on if any.""" + if not self.available: + return None + if self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug][ATTR_ICON]: + return f"/api/hassio/addons/{self._addon_slug}/icon" + return None + + async def async_install( + self, + version: str | None = None, + backup: bool | None = False, + **kwargs: Any, + ) -> None: + """Install an update.""" + try: + await async_update_addon(self.hass, slug=self._addon_slug, backup=backup) + except HassioAPIError as err: + raise HomeAssistantError(f"Error updating {self.title}: {err}") from err + else: + await self.coordinator.force_info_update_supervisor() + + +class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity): + """Update entity to handle updates for the Home Assistant Operating System.""" + + _attr_supported_features = ( + UpdateEntityFeature.INSTALL | UpdateEntityFeature.SPECIFIC_VERSION + ) + _attr_title = "Home Assistant Operating System" + + @property + def latest_version(self) -> str: + """Return native value of entity.""" + return self.coordinator.data[DATA_KEY_OS][ATTR_VERSION_LATEST] + + @property + def current_version(self) -> str: + """Return native value of entity.""" + return self.coordinator.data[DATA_KEY_OS][ATTR_VERSION] + + @property + def entity_picture(self) -> str | None: + """Return the iconof the entity.""" + return "https://brands.home-assistant.io/homeassistant/icon.png" + + @property + def release_url(self) -> str | None: + """URL to the full release notes of the latest version available.""" + version = AwesomeVersion(self.latest_version) + if version.dev or version.strategy == AwesomeVersionStrategy.UNKNOWN: + return "https://github.com/home-assistant/operating-system/commits/dev" + return ( + f"https://github.com/home-assistant/operating-system/releases/tag/{version}" + ) + + async def async_install( + self, + version: str | None = None, + backup: bool | None = None, + **kwargs: Any, + ) -> None: + """Install an update.""" + try: + await async_update_os(self.hass, version) + except HassioAPIError as err: + raise HomeAssistantError( + f"Error updating Home Assistant Operating System: {err}" + ) from err + + +class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity): + """Update entity to handle updates for the Home Assistant Supervisor.""" + + _attr_supported_features = UpdateEntityFeature.INSTALL + _attr_title = "Home Assistant Supervisor" + + @property + def latest_version(self) -> str: + """Return native value of entity.""" + return self.coordinator.data[DATA_KEY_SUPERVISOR][ATTR_VERSION_LATEST] + + @property + def current_version(self) -> str: + """Return native value of entity.""" + return self.coordinator.data[DATA_KEY_SUPERVISOR][ATTR_VERSION] + + @property + def release_url(self) -> str | None: + """URL to the full release notes of the latest version available.""" + version = AwesomeVersion(self.latest_version) + if version.dev or version.strategy == AwesomeVersionStrategy.UNKNOWN: + return "https://github.com/home-assistant/supervisor/commits/main" + return f"https://github.com/home-assistant/supervisor/releases/tag/{version}" + + @property + def entity_picture(self) -> str | None: + """Return the iconof the entity.""" + return "https://brands.home-assistant.io/hassio/icon.png" + + async def async_install( + self, + version: str | None = None, + backup: bool | None = None, + **kwargs: Any, + ) -> None: + """Install an update.""" + try: + await async_update_supervisor(self.hass) + except HassioAPIError as err: + raise HomeAssistantError( + f"Error updating Home Assistant Supervisor: {err}" + ) from err + + +class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity): + """Update entity to handle updates for Home Assistant Core.""" + + _attr_supported_features = ( + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.SPECIFIC_VERSION + | UpdateEntityFeature.BACKUP + ) + _attr_title = "Home Assistant Core" + + @property + def latest_version(self) -> str: + """Return native value of entity.""" + return self.coordinator.data[DATA_KEY_CORE][ATTR_VERSION_LATEST] + + @property + def current_version(self) -> str: + """Return native value of entity.""" + return self.coordinator.data[DATA_KEY_CORE][ATTR_VERSION] + + @property + def entity_picture(self) -> str | None: + """Return the iconof the entity.""" + return "https://brands.home-assistant.io/homeassistant/icon.png" + + @property + def release_url(self) -> str | None: + """URL to the full release notes of the latest version available.""" + version = AwesomeVersion(self.latest_version) + if version.dev: + return "https://github.com/home-assistant/core/commits/dev" + return f"https://{'rc' if version.beta else 'www'}.home-assistant.io/latest-release-notes/" + + async def async_install( + self, + version: str | None = None, + backup: bool | None = None, + **kwargs: Any, + ) -> None: + """Install an update.""" + try: + await async_update_core(self.hass, version=version, backup=backup) + except HassioAPIError as err: + raise HomeAssistantError( + f"Error updating Home Assistant Core {err}" + ) from err diff --git a/tests/components/hassio/test_binary_sensor.py b/tests/components/hassio/test_binary_sensor.py index e4263eb5529..e48f8a6a481 100644 --- a/tests/components/hassio/test_binary_sensor.py +++ b/tests/components/hassio/test_binary_sensor.py @@ -69,6 +69,7 @@ def mock_all(aioclient_mock, request): "result": "ok", "data": { "result": "ok", + "version": "1.0.0", "version_latest": "1.0.0", "addons": [ { @@ -113,6 +114,8 @@ def mock_all(aioclient_mock, request): }, }, ) + aioclient_mock.get("http://127.0.0.1/addons/test/changelog", text="") + aioclient_mock.get("http://127.0.0.1/addons/test2/changelog", text="") aioclient_mock.get( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 689ec138043..30776fd5b17 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -55,7 +55,7 @@ def mock_all(aioclient_mock, request): ) aioclient_mock.get( "http://127.0.0.1/core/info", - json={"result": "ok", "data": {"version_latest": "1.0.0"}}, + json={"result": "ok", "data": {"version_latest": "1.0.0", "version": "1.0.0"}}, ) aioclient_mock.get( "http://127.0.0.1/os/info", @@ -65,7 +65,7 @@ def mock_all(aioclient_mock, request): "http://127.0.0.1/supervisor/info", json={ "result": "ok", - "data": {"version_latest": "1.0.0"}, + "data": {"version_latest": "1.0.0", "version": "1.0.0"}, "addons": [ { "name": "test", @@ -138,6 +138,8 @@ def mock_all(aioclient_mock, request): }, }, ) + aioclient_mock.get("http://127.0.0.1/addons/test/changelog", text="") + aioclient_mock.get("http://127.0.0.1/addons/test2/changelog", text="") aioclient_mock.get( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) @@ -496,12 +498,15 @@ async def test_device_registry_calls(hass): """Test device registry entries for hassio.""" dev_reg = async_get(hass) supervisor_mock_data = { + "version": "1.0.0", + "version_latest": "1.0.0", "addons": [ { "name": "test", "state": "started", "slug": "test", "installed": True, + "icon": False, "update_available": False, "version": "1.0.0", "version_latest": "1.0.0", @@ -513,12 +518,13 @@ async def test_device_registry_calls(hass): "state": "started", "slug": "test2", "installed": True, + "icon": False, "update_available": False, "version": "1.0.0", "version_latest": "1.0.0", "url": "https://github.com", }, - ] + ], } os_mock_data = { "board": "odroid-n2", @@ -539,21 +545,24 @@ async def test_device_registry_calls(hass): config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(dev_reg.devices) == 3 + assert len(dev_reg.devices) == 5 supervisor_mock_data = { + "version": "1.0.0", + "version_latest": "1.0.0", "addons": [ { "name": "test2", "state": "started", "slug": "test2", "installed": True, + "icon": False, "update_available": False, "version": "1.0.0", "version_latest": "1.0.0", "url": "https://github.com", }, - ] + ], } # Test that when addon is removed, next update will remove the add-on and subsequent updates won't @@ -566,19 +575,22 @@ async def test_device_registry_calls(hass): ): async_fire_time_changed(hass, dt_util.now() + timedelta(hours=1)) await hass.async_block_till_done() - assert len(dev_reg.devices) == 2 + assert len(dev_reg.devices) == 4 async_fire_time_changed(hass, dt_util.now() + timedelta(hours=2)) await hass.async_block_till_done() - assert len(dev_reg.devices) == 2 + assert len(dev_reg.devices) == 4 supervisor_mock_data = { + "version": "1.0.0", + "version_latest": "1.0.0", "addons": [ { "name": "test2", "slug": "test2", "state": "started", "installed": True, + "icon": False, "update_available": False, "version": "1.0.0", "version_latest": "1.0.0", @@ -589,12 +601,13 @@ async def test_device_registry_calls(hass): "slug": "test3", "state": "stopped", "installed": True, + "icon": False, "update_available": False, "version": "1.0.0", "version_latest": "1.0.0", "url": "https://github.com", }, - ] + ], } # Test that when addon is added, next update will reload the entry so we register @@ -608,4 +621,4 @@ async def test_device_registry_calls(hass): ): async_fire_time_changed(hass, dt_util.now() + timedelta(hours=3)) await hass.async_block_till_done() - assert len(dev_reg.devices) == 3 + assert len(dev_reg.devices) == 5 diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 481ba1b578f..df309e94360 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -62,6 +62,7 @@ def mock_all(aioclient_mock, request): "result": "ok", "data": { "result": "ok", + "version": "1.0.0", "version_latest": "1.0.0", "addons": [ { @@ -106,6 +107,8 @@ def mock_all(aioclient_mock, request): }, }, ) + aioclient_mock.get("http://127.0.0.1/addons/test/changelog", text="") + aioclient_mock.get("http://127.0.0.1/addons/test2/changelog", text="") aioclient_mock.get( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py new file mode 100644 index 00000000000..3c5f7b52e56 --- /dev/null +++ b/tests/components/hassio/test_update.py @@ -0,0 +1,372 @@ +"""The tests for the hassio update entities.""" + +import os +from unittest.mock import patch + +import pytest + +from homeassistant.components.hassio import DOMAIN +from homeassistant.components.hassio.handler import HassioAPIError +from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +MOCK_ENVIRON = {"HASSIO": "127.0.0.1", "HASSIO_TOKEN": "abcdefgh"} + + +@pytest.fixture(autouse=True) +def mock_all(aioclient_mock, request): + """Mock all setup requests.""" + aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) + aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "ok"}) + aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"}) + aioclient_mock.get( + "http://127.0.0.1/info", + json={ + "result": "ok", + "data": {"supervisor": "222", "homeassistant": "0.110.0", "hassos": None}, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/store", + json={ + "result": "ok", + "data": {"addons": [], "repositories": []}, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/host/info", + json={ + "result": "ok", + "data": { + "result": "ok", + "data": { + "chassis": "vm", + "operating_system": "Debian GNU/Linux 10 (buster)", + "kernel": "4.19.0-6-amd64", + }, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/core/info", + json={ + "result": "ok", + "data": {"version_latest": "1.0.0dev222", "version": "1.0.0dev221"}, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/os/info", + json={ + "result": "ok", + "data": { + "version_latest": "1.0.0dev2222", + "version": "1.0.0dev2221", + "update_available": False, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/info", + json={ + "result": "ok", + "data": { + "result": "ok", + "version": "1.0.0", + "version_latest": "1.0.1dev222", + "addons": [ + { + "name": "test", + "state": "started", + "slug": "test", + "installed": True, + "update_available": True, + "icon": False, + "version": "2.0.0", + "version_latest": "2.0.1", + "repository": "core", + "url": "https://github.com/home-assistant/addons/test", + }, + { + "name": "test2", + "state": "stopped", + "slug": "test2", + "installed": True, + "update_available": False, + "icon": True, + "version": "3.1.0", + "version_latest": "3.1.0", + "repository": "core", + "url": "https://github.com", + }, + ], + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/addons/test/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get("http://127.0.0.1/addons/test/changelog", text="") + aioclient_mock.get("http://127.0.0.1/addons/test2/changelog", text="") + aioclient_mock.get( + "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} + ) + + +@pytest.mark.parametrize( + "entity_id,expected", + [ + ("update.home_assistant_operating_system_update", "on"), + ("update.home_assistant_supervisor_update", "on"), + ("update.home_assistant_core_update", "on"), + ("update.test_update", "on"), + ("update.test2_update", "off"), + ], +) +async def test_update_entities(hass, entity_id, expected, aioclient_mock): + """Test update entities.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + # Verify that the entity have the expected state. + state = hass.states.get(entity_id) + assert state.state == expected + + +async def test_update_addon(hass, aioclient_mock): + """Test updating addon update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + aioclient_mock.post( + "http://127.0.0.1/addons/test/update", + json={"result": "ok", "data": {}}, + ) + + assert await hass.services.async_call( + "update", + "install", + {"entity_id": "update.test_update"}, + blocking=True, + ) + + +async def test_update_os(hass, aioclient_mock): + """Test updating OS update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + aioclient_mock.post( + "http://127.0.0.1/os/update", + json={"result": "ok", "data": {}}, + ) + + assert await hass.services.async_call( + "update", + "install", + {"entity_id": "update.home_assistant_operating_system_update"}, + blocking=True, + ) + + +async def test_update_core(hass, aioclient_mock): + """Test updating core update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + aioclient_mock.post( + "http://127.0.0.1/core/update", + json={"result": "ok", "data": {}}, + ) + + assert await hass.services.async_call( + "update", + "install", + {"entity_id": "update.home_assistant_os_update"}, + blocking=True, + ) + + +async def test_update_supervisor(hass, aioclient_mock): + """Test updating supervisor update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + aioclient_mock.post( + "http://127.0.0.1/supervisor/update", + json={"result": "ok", "data": {}}, + ) + + assert await hass.services.async_call( + "update", + "install", + {"entity_id": "update.home_assistant_supervisor_update"}, + blocking=True, + ) + + +async def test_update_addon_with_error(hass, aioclient_mock): + """Test updating addon update entity with error.""" + 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() + + aioclient_mock.post( + "http://127.0.0.1/addons/test/update", + exc=HassioAPIError, + ) + + with pytest.raises(HomeAssistantError): + assert not await hass.services.async_call( + "update", + "install", + {"entity_id": "update.test_update"}, + blocking=True, + ) + + +async def test_update_os_with_error(hass, aioclient_mock): + """Test updating OS update entity with error.""" + 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() + + aioclient_mock.post( + "http://127.0.0.1/os/update", + exc=HassioAPIError, + ) + + with pytest.raises(HomeAssistantError): + assert not await hass.services.async_call( + "update", + "install", + {"entity_id": "update.home_assistant_operating_system_update"}, + blocking=True, + ) + + +async def test_update_supervisor_with_error(hass, aioclient_mock): + """Test updating supervisor update entity with error.""" + 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() + + aioclient_mock.post( + "http://127.0.0.1/supervisor/update", + exc=HassioAPIError, + ) + + with pytest.raises(HomeAssistantError): + assert not await hass.services.async_call( + "update", + "install", + {"entity_id": "update.home_assistant_supervisor_update"}, + blocking=True, + ) + + +async def test_update_core_with_error(hass, aioclient_mock): + """Test updating core update entity with error.""" + 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() + + aioclient_mock.post( + "http://127.0.0.1/core/update", + exc=HassioAPIError, + ) + + with pytest.raises(HomeAssistantError): + assert not await hass.services.async_call( + "update", + "install", + {"entity_id": "update.home_assistant_core_update"}, + blocking=True, + )