From 0592309b652eaf541e281d64e3149b021cb2f919 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 1 Mar 2021 03:41:04 -0500 Subject: [PATCH] Add hassio addon_update service and hassio config entry with addon and OS devices and entities (#46342) * add addon_update service, use config flow to set up config entry, create disabled sensors * move most of entity logic to common entity class, improve device info, get rid of config_flow user step * fix setup logic * additional refactor * fix refactored logic * fix config flow tests * add test for addon_update service and get_addons_info * add entry setup and unload test and fix update coordinator * handle if entry setup calls unload * return nothing for coordinator if entry is being reloaded because coordinator will get recreated anyway * remove entry when HA instance is no longer hassio and add corresponding test * handle adding and removing device registry entries * better config entry reload logic * fix comment * bugfix * fix flake error * switch pass to return * use repository attribute for model and fallback to url * use custom 'system' source since hassio source is misleading * Update homeassistant/components/hassio/entity.py Co-authored-by: Franck Nijhof * update remove addons function name * Update homeassistant/components/hassio/__init__.py Co-authored-by: Franck Nijhof * fix import * pop coordinator after unload * additional fixes * always pass in sensor name when creating entity * prefix one more function with async and fix tests * use supervisor info for addons since list is already filtered on what's installed * remove unused service * update sensor names * remove added handler function * use walrus * add OS device and sensors * fix * re-add addon_update service schema * add more test coverage and exclude entities from tests * check if instance is using hass OS in order to create OS entities Co-authored-by: Franck Nijhof --- .coveragerc | 3 + homeassistant/components/hassio/__init__.py | 165 ++++++++++++- .../components/hassio/binary_sensor.py | 50 ++++ .../components/hassio/config_flow.py | 22 ++ homeassistant/components/hassio/const.py | 9 +- homeassistant/components/hassio/entity.py | 93 +++++++ homeassistant/components/hassio/sensor.py | 52 ++++ homeassistant/components/hassio/services.yaml | 12 + tests/components/hassio/test_config_flow.py | 36 +++ tests/components/hassio/test_init.py | 229 +++++++++++++++++- 10 files changed, 661 insertions(+), 10 deletions(-) create mode 100644 homeassistant/components/hassio/binary_sensor.py create mode 100644 homeassistant/components/hassio/config_flow.py create mode 100644 homeassistant/components/hassio/entity.py create mode 100644 homeassistant/components/hassio/sensor.py create mode 100644 tests/components/hassio/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 50fcf151821..281c8d5be83 100644 --- a/.coveragerc +++ b/.coveragerc @@ -377,6 +377,9 @@ omit = homeassistant/components/harmony/data.py homeassistant/components/harmony/remote.py homeassistant/components/harmony/util.py + homeassistant/components/hassio/binary_sensor.py + homeassistant/components/hassio/entity.py + homeassistant/components/hassio/sensor.py homeassistant/components/haveibeenpwned/sensor.py homeassistant/components/hdmi_cec/* homeassistant/components/heatmiser/climate.py diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index fdeb10bcafe..82797874445 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -1,24 +1,29 @@ """Support for Hass.io.""" +import asyncio from datetime import timedelta import logging import os -from typing import Optional +from typing import Any, Dict, List, Optional import voluptuous as vol from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components.homeassistant import SERVICE_CHECK_CONFIG import homeassistant.config as conf_util +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_NAME, + ATTR_SERVICE, EVENT_CORE_CONFIG_UPDATE, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP, ) -from homeassistant.core import DOMAIN as HASS_DOMAIN, callback +from homeassistant.core import DOMAIN as HASS_DOMAIN, Config, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceRegistry, async_get_registry from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.loader import bind_hass from homeassistant.util.dt import utcnow @@ -32,7 +37,11 @@ from .const import ( ATTR_HOMEASSISTANT, ATTR_INPUT, ATTR_PASSWORD, + ATTR_REPOSITORY, + ATTR_SLUG, ATTR_SNAPSHOT, + ATTR_URL, + ATTR_VERSION, DOMAIN, ) from .discovery import async_setup_discovery_view @@ -46,6 +55,7 @@ _LOGGER = logging.getLogger(__name__) STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 +PLATFORMS = ["binary_sensor", "sensor"] CONF_FRONTEND_REPO = "development_repo" @@ -62,9 +72,12 @@ DATA_OS_INFO = "hassio_os_info" DATA_SUPERVISOR_INFO = "hassio_supervisor_info" HASSIO_UPDATE_INTERVAL = timedelta(minutes=55) +ADDONS_COORDINATOR = "hassio_addons_coordinator" + SERVICE_ADDON_START = "addon_start" SERVICE_ADDON_STOP = "addon_stop" SERVICE_ADDON_RESTART = "addon_restart" +SERVICE_ADDON_UPDATE = "addon_update" SERVICE_ADDON_STDIN = "addon_stdin" SERVICE_HOST_SHUTDOWN = "host_shutdown" SERVICE_HOST_REBOOT = "host_reboot" @@ -110,6 +123,7 @@ MAP_SERVICE_API = { SERVICE_ADDON_START: ("/addons/{addon}/start", SCHEMA_ADDON, 60, False), SERVICE_ADDON_STOP: ("/addons/{addon}/stop", SCHEMA_ADDON, 60, False), SERVICE_ADDON_RESTART: ("/addons/{addon}/restart", SCHEMA_ADDON, 60, False), + SERVICE_ADDON_UPDATE: ("/addons/{addon}/update", SCHEMA_ADDON, 60, False), SERVICE_ADDON_STDIN: ("/addons/{addon}/stdin", SCHEMA_ADDON_STDIN, 60, False), SERVICE_HOST_SHUTDOWN: ("/host/shutdown", SCHEMA_NO_DATA, 60, False), SERVICE_HOST_REBOOT: ("/host/reboot", SCHEMA_NO_DATA, 60, False), @@ -286,13 +300,17 @@ def get_supervisor_ip(): return os.environ["SUPERVISOR"].partition(":")[0] -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: Config) -> bool: """Set up the Hass.io component.""" # Check local setup for env in ("HASSIO", "HASSIO_TOKEN"): if os.environ.get(env): continue _LOGGER.error("Missing %s environment variable", env) + if config_entries := hass.config_entries.async_entries(DOMAIN): + hass.async_create_task( + hass.config_entries.async_remove(config_entries[0].entry_id) + ) return False async_load_websocket_api(hass) @@ -402,6 +420,8 @@ async def async_setup(hass, config): hass.data[DATA_CORE_INFO] = await hassio.get_core_info() hass.data[DATA_SUPERVISOR_INFO] = await hassio.get_supervisor_info() hass.data[DATA_OS_INFO] = await hassio.get_os_info() + if ADDONS_COORDINATOR in hass.data: + await hass.data[ADDONS_COORDINATOR].async_refresh() except HassioAPIError as err: _LOGGER.warning("Can't read last version: %s", err) @@ -455,4 +475,143 @@ async def async_setup(hass, config): # Init add-on ingress panels await async_setup_addon_panel(hass, hassio) + hass.async_create_task( + hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"}) + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up a config entry.""" + dev_reg = await async_get_registry(hass) + coordinator = HassioDataUpdateCoordinator(hass, config_entry, dev_reg) + hass.data[ADDONS_COORDINATOR] = coordinator + await coordinator.async_refresh() + + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, platform) + ) + + return True + + +async def async_unload_entry( + hass: HomeAssistantType, config_entry: ConfigEntry +) -> bool: + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, platform) + for platform in PLATFORMS + ] + ) + ) + + # Pop add-on data + hass.data.pop(ADDONS_COORDINATOR, None) + + return unload_ok + + +@callback +def async_register_addons_in_dev_reg( + entry_id: str, dev_reg: DeviceRegistry, addons: List[Dict[str, Any]] +) -> None: + """Register addons in the device registry.""" + for addon in addons: + dev_reg.async_get_or_create( + config_entry_id=entry_id, + identifiers={(DOMAIN, addon[ATTR_SLUG])}, + manufacturer=addon.get(ATTR_REPOSITORY) or addon.get(ATTR_URL) or "unknown", + model="Home Assistant Add-on", + sw_version=addon[ATTR_VERSION], + name=addon[ATTR_NAME], + entry_type=ATTR_SERVICE, + ) + + +@callback +def async_register_os_in_dev_reg( + entry_id: str, dev_reg: DeviceRegistry, os_dict: Dict[str, Any] +) -> None: + """Register OS in the device registry.""" + dev_reg.async_get_or_create( + config_entry_id=entry_id, + identifiers={(DOMAIN, "OS")}, + manufacturer="Home Assistant", + model="Home Assistant Operating System", + sw_version=os_dict[ATTR_VERSION], + name="Home Assistant Operating System", + entry_type=ATTR_SERVICE, + ) + + +@callback +def async_remove_addons_from_dev_reg( + dev_reg: DeviceRegistry, addons: List[Dict[str, Any]] +) -> None: + """Remove addons from the device registry.""" + for addon_slug in addons: + if dev := dev_reg.async_get_device({(DOMAIN, addon_slug)}): + dev_reg.async_remove_device(dev.id) + + +class HassioDataUpdateCoordinator(DataUpdateCoordinator): + """Class to retrieve Hass.io status.""" + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, dev_reg: DeviceRegistry + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_method=self._async_update_data, + ) + self.data = {} + self.entry_id = config_entry.entry_id + self.dev_reg = dev_reg + self.is_hass_os = "hassos" in get_info(self.hass) + + async def _async_update_data(self) -> Dict[str, Any]: + """Update data via library.""" + new_data = {} + addon_data = get_supervisor_info(self.hass) + + new_data["addons"] = { + addon[ATTR_SLUG]: addon for addon in addon_data.get("addons", []) + } + if self.is_hass_os: + new_data["os"] = get_os_info(self.hass) + + # 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["addons"].values() + ) + if self.is_hass_os: + async_register_os_in_dev_reg( + self.entry_id, self.dev_reg, new_data["os"] + ) + return new_data + + # Remove add-ons that are no longer installed from device registry + if removed_addons := list( + set(self.data["addons"].keys()) - set(new_data["addons"].keys()) + ): + async_remove_addons_from_dev_reg(self.dev_reg, removed_addons) + + # If there are new add-ons, we should reload the config entry so we can + # create new devices and entities. We can return an empty dict because + # coordinator will be recreated. + if list(set(new_data["addons"].keys()) - set(self.data["addons"].keys())): + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.entry_id) + ) + return {} + + return new_data diff --git a/homeassistant/components/hassio/binary_sensor.py b/homeassistant/components/hassio/binary_sensor.py new file mode 100644 index 00000000000..c3daaa07f28 --- /dev/null +++ b/homeassistant/components/hassio/binary_sensor.py @@ -0,0 +1,50 @@ +"""Binary sensor platform for Hass.io addons.""" +from typing import Callable, List + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity + +from . import ADDONS_COORDINATOR +from .const import ATTR_UPDATE_AVAILABLE +from .entity import HassioAddonEntity, HassioOSEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], bool], None], +) -> None: + """Binary sensor set up for Hass.io config entry.""" + coordinator = hass.data[ADDONS_COORDINATOR] + + entities = [ + HassioAddonBinarySensor( + coordinator, addon, ATTR_UPDATE_AVAILABLE, "Update Available" + ) + for addon in coordinator.data.values() + ] + if coordinator.is_hass_os: + entities.append( + HassioOSBinarySensor(coordinator, ATTR_UPDATE_AVAILABLE, "Update Available") + ) + async_add_entities(entities) + + +class HassioAddonBinarySensor(HassioAddonEntity, BinarySensorEntity): + """Binary sensor to track whether an update is available for a Hass.io add-on.""" + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.addon_info[self.attribute_name] + + +class HassioOSBinarySensor(HassioOSEntity, BinarySensorEntity): + """Binary sensor to track whether an update is available for Hass.io OS.""" + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.os_info[self.attribute_name] diff --git a/homeassistant/components/hassio/config_flow.py b/homeassistant/components/hassio/config_flow.py new file mode 100644 index 00000000000..56c7d324a61 --- /dev/null +++ b/homeassistant/components/hassio/config_flow.py @@ -0,0 +1,22 @@ +"""Config flow for Home Assistant Supervisor integration.""" +import logging + +from homeassistant import config_entries + +from . import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Home Assistant Supervisor.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + async def async_step_system(self, user_input=None): + """Handle the initial step.""" + # We only need one Hass.io config entry + await self.async_set_unique_id(DOMAIN) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=DOMAIN.title(), data={}) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index b2878c8143f..417a62a1a8c 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -29,7 +29,6 @@ X_INGRESS_PATH = "X-Ingress-Path" X_HASS_USER_ID = "X-Hass-User-ID" X_HASS_IS_ADMIN = "X-Hass-Is-Admin" - WS_TYPE = "type" WS_ID = "id" @@ -38,3 +37,11 @@ WS_TYPE_EVENT = "supervisor/event" WS_TYPE_SUBSCRIBE = "supervisor/subscribe" EVENT_SUPERVISOR_EVENT = "supervisor_event" + +# Add-on keys +ATTR_VERSION = "version" +ATTR_VERSION_LATEST = "version_latest" +ATTR_UPDATE_AVAILABLE = "update_available" +ATTR_SLUG = "slug" +ATTR_URL = "url" +ATTR_REPOSITORY = "repository" diff --git a/homeassistant/components/hassio/entity.py b/homeassistant/components/hassio/entity.py new file mode 100644 index 00000000000..daadeb514a2 --- /dev/null +++ b/homeassistant/components/hassio/entity.py @@ -0,0 +1,93 @@ +"""Base for Hass.io entities.""" +from typing import Any, Dict + +from homeassistant.const import ATTR_NAME +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import DOMAIN, HassioDataUpdateCoordinator +from .const import ATTR_SLUG + + +class HassioAddonEntity(CoordinatorEntity): + """Base entity for a Hass.io add-on.""" + + def __init__( + self, + coordinator: HassioDataUpdateCoordinator, + addon: Dict[str, Any], + attribute_name: str, + sensor_name: str, + ) -> None: + """Initialize base entity.""" + self.addon_slug = addon[ATTR_SLUG] + self.addon_name = addon[ATTR_NAME] + self._data_key = "addons" + self.attribute_name = attribute_name + self.sensor_name = sensor_name + super().__init__(coordinator) + + @property + def addon_info(self) -> Dict[str, Any]: + """Return add-on info.""" + return self.coordinator.data[self._data_key][self.addon_slug] + + @property + def name(self) -> str: + """Return entity name.""" + return f"{self.addon_name}: {self.sensor_name}" + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return False + + @property + def unique_id(self) -> str: + """Return unique ID for entity.""" + return f"{self.addon_slug}_{self.attribute_name}" + + @property + def device_info(self) -> Dict[str, Any]: + """Return device specific attributes.""" + return {"identifiers": {(DOMAIN, self.addon_slug)}} + + +class HassioOSEntity(CoordinatorEntity): + """Base Entity for Hass.io OS.""" + + def __init__( + self, + coordinator: HassioDataUpdateCoordinator, + attribute_name: str, + sensor_name: str, + ) -> None: + """Initialize base entity.""" + self._data_key = "os" + self.attribute_name = attribute_name + self.sensor_name = sensor_name + super().__init__(coordinator) + + @property + def os_info(self) -> Dict[str, Any]: + """Return OS info.""" + return self.coordinator.data[self._data_key] + + @property + def name(self) -> str: + """Return entity name.""" + return f"Home Assistant Operating System: {self.sensor_name}" + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return False + + @property + def unique_id(self) -> str: + """Return unique ID for entity.""" + return f"home_assistant_os_{self.attribute_name}" + + @property + def device_info(self) -> Dict[str, Any]: + """Return device specific attributes.""" + return {"identifiers": {(DOMAIN, "OS")}} diff --git a/homeassistant/components/hassio/sensor.py b/homeassistant/components/hassio/sensor.py new file mode 100644 index 00000000000..857f4831587 --- /dev/null +++ b/homeassistant/components/hassio/sensor.py @@ -0,0 +1,52 @@ +"""Sensor platform for Hass.io addons.""" +from typing import Callable, List + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity + +from . import ADDONS_COORDINATOR +from .const import ATTR_VERSION, ATTR_VERSION_LATEST +from .entity import HassioAddonEntity, HassioOSEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], bool], None], +) -> None: + """Sensor set up for Hass.io config entry.""" + coordinator = hass.data[ADDONS_COORDINATOR] + + entities = [] + + for attribute_name, sensor_name in ( + (ATTR_VERSION, "Version"), + (ATTR_VERSION_LATEST, "Newest Version"), + ): + for addon in coordinator.data.values(): + entities.append( + HassioAddonSensor(coordinator, addon, attribute_name, sensor_name) + ) + if coordinator.is_hass_os: + entities.append(HassioOSSensor(coordinator, attribute_name, sensor_name)) + + async_add_entities(entities) + + +class HassioAddonSensor(HassioAddonEntity): + """Sensor to track a Hass.io add-on attribute.""" + + @property + def state(self) -> str: + """Return state of entity.""" + return self.addon_info[self.attribute_name] + + +class HassioOSSensor(HassioOSEntity): + """Sensor to track a Hass.io add-on attribute.""" + + @property + def state(self) -> str: + """Return state of entity.""" + return self.os_info[self.attribute_name] diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml index 3570a857c55..0652b65d6e2 100644 --- a/homeassistant/components/hassio/services.yaml +++ b/homeassistant/components/hassio/services.yaml @@ -46,6 +46,18 @@ addon_stop: selector: addon: +addon_update: + name: Update add-on. + description: Update add-on. This service should be used with caution since add-on updates can contain breaking changes. It is highly recommended that you review release notes/change logs before updating an add-on. + fields: + addon: + name: Add-on + required: true + description: The add-on slug. + example: core_ssh + selector: + addon: + host_reboot: name: Reboot the host system. description: Reboot the host system. diff --git a/tests/components/hassio/test_config_flow.py b/tests/components/hassio/test_config_flow.py new file mode 100644 index 00000000000..c2d306183f0 --- /dev/null +++ b/tests/components/hassio/test_config_flow.py @@ -0,0 +1,36 @@ +"""Test the Home Assistant Supervisor config flow.""" +from unittest.mock import patch + +from homeassistant import setup +from homeassistant.components.hassio import DOMAIN + + +async def test_config_flow(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( + "homeassistant.components.hassio.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.hassio.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "system"} + ) + assert result["type"] == "create_entry" + assert result["title"] == DOMAIN.title() + assert result["data"] == {} + await hass.async_block_till_done() + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_multiple_entries(hass): + """Test creating multiple hassio entries.""" + await test_config_flow(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "system"} + ) + assert result["type"] == "abort" + assert result["reason"] == "already_configured" diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 2efb5b0744e..bd9eb30be5c 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -1,17 +1,91 @@ """The tests for the hassio component.""" +from datetime import timedelta import os from unittest.mock import patch +import pytest + from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components import frontend -from homeassistant.components.hassio import STORAGE_KEY +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.hassio import ADDONS_COORDINATOR, DOMAIN, STORAGE_KEY +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.helpers.device_registry import async_get from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util -from . import mock_all # noqa +from tests.common import MockConfigEntry, async_fire_time_changed 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/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.0"}}, + ) + aioclient_mock.get( + "http://127.0.0.1/os/info", + json={"result": "ok", "data": {"version_latest": "1.0.0"}}, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/info", + json={ + "result": "ok", + "data": {"version_latest": "1.0.0"}, + "addons": [ + { + "name": "test", + "slug": "test", + "installed": True, + "update_available": False, + "version": "1.0.0", + "version_latest": "1.0.0", + "url": "https://github.com/home-assistant/addons/test", + }, + { + "name": "test2", + "slug": "test2", + "installed": True, + "update_available": False, + "version": "1.0.0", + "version_latest": "1.0.0", + "url": "https://github.com", + }, + ], + }, + ) + aioclient_mock.get( + "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} + ) + + async def test_setup_api_ping(hass, aioclient_mock): """Test setup with API ping.""" with patch.dict(os.environ, MOCK_ENVIRON): @@ -193,6 +267,7 @@ async def test_service_register(hassio_env, hass): assert hass.services.has_service("hassio", "addon_start") assert hass.services.has_service("hassio", "addon_stop") assert hass.services.has_service("hassio", "addon_restart") + assert hass.services.has_service("hassio", "addon_update") assert hass.services.has_service("hassio", "addon_stdin") assert hass.services.has_service("hassio", "host_shutdown") assert hass.services.has_service("hassio", "host_reboot") @@ -210,6 +285,7 @@ async def test_service_calls(hassio_env, hass, aioclient_mock): aioclient_mock.post("http://127.0.0.1/addons/test/start", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/addons/test/stop", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/addons/test/restart", json={"result": "ok"}) + aioclient_mock.post("http://127.0.0.1/addons/test/update", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/addons/test/stdin", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/host/shutdown", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/host/reboot", json={"result": "ok"}) @@ -225,19 +301,20 @@ async def test_service_calls(hassio_env, hass, aioclient_mock): await hass.services.async_call("hassio", "addon_start", {"addon": "test"}) await hass.services.async_call("hassio", "addon_stop", {"addon": "test"}) await hass.services.async_call("hassio", "addon_restart", {"addon": "test"}) + await hass.services.async_call("hassio", "addon_update", {"addon": "test"}) await hass.services.async_call( "hassio", "addon_stdin", {"addon": "test", "input": "test"} ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 7 + assert aioclient_mock.call_count == 8 assert aioclient_mock.mock_calls[-1][2] == "test" await hass.services.async_call("hassio", "host_shutdown", {}) await hass.services.async_call("hassio", "host_reboot", {}) await hass.async_block_till_done() - assert aioclient_mock.call_count == 9 + assert aioclient_mock.call_count == 10 await hass.services.async_call("hassio", "snapshot_full", {}) await hass.services.async_call( @@ -247,7 +324,7 @@ async def test_service_calls(hassio_env, hass, aioclient_mock): ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 11 + assert aioclient_mock.call_count == 12 assert aioclient_mock.mock_calls[-1][2] == { "addons": ["test"], "folders": ["ssl"], @@ -268,7 +345,7 @@ async def test_service_calls(hassio_env, hass, aioclient_mock): ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 13 + assert aioclient_mock.call_count == 14 assert aioclient_mock.mock_calls[-1][2] == { "addons": ["test"], "folders": ["ssl"], @@ -302,3 +379,143 @@ async def test_service_calls_core(hassio_env, hass, aioclient_mock): assert mock_check_config.called assert aioclient_mock.call_count == 5 + + +async def test_entry_load_and_unload(hass): + """Test loading and unloading config entry.""" + with patch.dict(os.environ, MOCK_ENVIRON): + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert SENSOR_DOMAIN in hass.config.components + assert BINARY_SENSOR_DOMAIN in hass.config.components + assert ADDONS_COORDINATOR in hass.data + + assert await config_entry.async_unload(hass) + await hass.async_block_till_done() + assert ADDONS_COORDINATOR not in hass.data + + +async def test_migration_off_hassio(hass): + """Test that when a user moves instance off Hass.io, config entry gets cleaned up.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN) == [] + + +async def test_device_registry_calls(hass): + """Test device registry entries for hassio.""" + dev_reg = async_get(hass) + supervisor_mock_data = { + "addons": [ + { + "name": "test", + "slug": "test", + "installed": True, + "update_available": False, + "version": "1.0.0", + "version_latest": "1.0.0", + "repository": "test", + "url": "https://github.com/home-assistant/addons/test", + }, + { + "name": "test2", + "slug": "test2", + "installed": True, + "update_available": False, + "version": "1.0.0", + "version_latest": "1.0.0", + "url": "https://github.com", + }, + ] + } + os_mock_data = { + "board": "odroid-n2", + "boot": "A", + "update_available": False, + "version": "5.12", + "version_latest": "5.12", + } + + with patch.dict(os.environ, MOCK_ENVIRON), patch( + "homeassistant.components.hassio.HassIO.get_supervisor_info", + return_value=supervisor_mock_data, + ), patch( + "homeassistant.components.hassio.HassIO.get_os_info", + return_value=os_mock_data, + ): + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + 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 + + supervisor_mock_data = { + "addons": [ + { + "name": "test2", + "slug": "test2", + "installed": True, + "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 + with patch( + "homeassistant.components.hassio.HassIO.get_supervisor_info", + return_value=supervisor_mock_data, + ), patch( + "homeassistant.components.hassio.HassIO.get_os_info", + return_value=os_mock_data, + ): + async_fire_time_changed(hass, dt_util.now() + timedelta(hours=1)) + await hass.async_block_till_done() + assert len(dev_reg.devices) == 2 + + async_fire_time_changed(hass, dt_util.now() + timedelta(hours=2)) + await hass.async_block_till_done() + assert len(dev_reg.devices) == 2 + + supervisor_mock_data = { + "addons": [ + { + "name": "test2", + "slug": "test2", + "installed": True, + "update_available": False, + "version": "1.0.0", + "version_latest": "1.0.0", + "url": "https://github.com", + }, + { + "name": "test3", + "slug": "test3", + "installed": True, + "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 + # a new device + with patch( + "homeassistant.components.hassio.HassIO.get_supervisor_info", + return_value=supervisor_mock_data, + ), patch( + "homeassistant.components.hassio.HassIO.get_os_info", + return_value=os_mock_data, + ): + async_fire_time_changed(hass, dt_util.now() + timedelta(hours=3)) + await hass.async_block_till_done() + assert len(dev_reg.devices) == 3