diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 78e9c40cebd..e7ab7aac3c8 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -34,6 +34,7 @@ from homeassistant.core import ( from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.event import async_call_later from homeassistant.helpers.storage import Store @@ -74,6 +75,7 @@ from .const import ( DATA_KEY_SUPERVISOR, DATA_KEY_SUPERVISOR_ISSUES, DOMAIN, + REQUEST_REFRESH_DELAY, SUPERVISOR_CONTAINER, SupervisorEntityModel, ) @@ -334,7 +336,7 @@ def get_addons_stats(hass): Async friendly. """ - return hass.data.get(DATA_ADDONS_STATS) + return hass.data.get(DATA_ADDONS_STATS) or {} @callback @@ -344,7 +346,7 @@ def get_core_stats(hass): Async friendly. """ - return hass.data.get(DATA_CORE_STATS) + return hass.data.get(DATA_CORE_STATS) or {} @callback @@ -354,7 +356,7 @@ def get_supervisor_stats(hass): Async friendly. """ - return hass.data.get(DATA_SUPERVISOR_STATS) + return hass.data.get(DATA_SUPERVISOR_STATS) or {} @callback @@ -754,6 +756,12 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): _LOGGER, name=DOMAIN, update_interval=HASSIO_UPDATE_INTERVAL, + # We don't want an immediate refresh since we want to avoid + # fetching the container stats right away and avoid hammering + # the Supervisor API on startup + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), ) self.hassio: HassIO = hass.data[DOMAIN] self.data = {} @@ -875,9 +883,9 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): DATA_SUPERVISOR_INFO: hassio.get_supervisor_info(), DATA_OS_INFO: hassio.get_os_info(), } - if first_update or CONTAINER_STATS in container_updates[CORE_CONTAINER]: + if CONTAINER_STATS in container_updates[CORE_CONTAINER]: updates[DATA_CORE_STATS] = hassio.get_core_stats() - if first_update or CONTAINER_STATS in container_updates[SUPERVISOR_CONTAINER]: + if CONTAINER_STATS in container_updates[SUPERVISOR_CONTAINER]: updates[DATA_SUPERVISOR_STATS] = hassio.get_supervisor_stats() results = await asyncio.gather(*updates.values()) @@ -903,20 +911,28 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): # API calls since otherwise we would fetch stats for all containers # and throw them away. # - for data_key, update_func, enabled_key, wanted_addons in ( + for data_key, update_func, enabled_key, wanted_addons, needs_first_update in ( ( DATA_ADDONS_STATS, self._update_addon_stats, CONTAINER_STATS, started_addons, + False, ), ( DATA_ADDONS_CHANGELOGS, self._update_addon_changelog, CONTAINER_CHANGELOG, all_addons, + True, + ), + ( + DATA_ADDONS_INFO, + self._update_addon_info, + CONTAINER_INFO, + all_addons, + True, ), - (DATA_ADDONS_INFO, self._update_addon_info, CONTAINER_INFO, all_addons), ): container_data: dict[str, Any] = data.setdefault(data_key, {}) container_data.update( @@ -925,7 +941,8 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): *[ update_func(slug) for slug in wanted_addons - if first_update or enabled_key in container_updates[slug] + if (first_update and needs_first_update) + or enabled_key in container_updates[slug] ] ) ) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 193d4762c5a..b495745e87d 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -101,6 +101,8 @@ KEY_TO_UPDATE_TYPES: dict[str, set[str]] = { ATTR_STATE: {CONTAINER_INFO}, } +REQUEST_REFRESH_DELAY = 10 + class SupervisorEntityModel(StrEnum): """Supervisor entity model.""" diff --git a/homeassistant/components/hassio/entity.py b/homeassistant/components/hassio/entity.py index 16e418d91d5..63e0314dd05 100644 --- a/homeassistant/components/hassio/entity.py +++ b/homeassistant/components/hassio/entity.py @@ -10,6 +10,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import DOMAIN, HassioDataUpdateCoordinator from .const import ( ATTR_SLUG, + CONTAINER_STATS, CORE_CONTAINER, DATA_KEY_ADDONS, DATA_KEY_CORE, @@ -58,6 +59,8 @@ class HassioAddonEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): self._addon_slug, self.entity_id, update_types ) ) + if CONTAINER_STATS in update_types: + await self.coordinator.async_request_refresh() class HassioOSEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): @@ -147,6 +150,8 @@ class HassioSupervisorEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): SUPERVISOR_CONTAINER, self.entity_id, update_types ) ) + if CONTAINER_STATS in update_types: + await self.coordinator.async_request_refresh() class HassioCoreEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): @@ -183,3 +188,5 @@ class HassioCoreEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): CORE_CONTAINER, self.entity_id, update_types ) ) + if CONTAINER_STATS in update_types: + await self.coordinator.async_request_refresh() diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 99e1de6e763..4bf3e29154e 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -17,6 +17,7 @@ from homeassistant.components.hassio import ( async_get_addon_store_info, hostname_from_addon_slug, ) +from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant @@ -244,7 +245,7 @@ async def test_setup_api_ping( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert hass.components.hassio.get_core_info()["version_latest"] == "1.0.0" assert hass.components.hassio.is_hassio() @@ -289,7 +290,7 @@ async def test_setup_api_push_api_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert aioclient_mock.mock_calls[1][2]["watchdog"] @@ -308,7 +309,7 @@ async def test_setup_api_push_api_data_server_host( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert not aioclient_mock.mock_calls[1][2]["watchdog"] @@ -325,7 +326,7 @@ async def test_setup_api_push_api_data_default( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 refresh_token = aioclient_mock.mock_calls[1][2]["refresh_token"] @@ -405,7 +406,7 @@ async def test_setup_api_existing_hassio_user( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 assert aioclient_mock.mock_calls[1][2]["refresh_token"] == token.token @@ -422,7 +423,7 @@ async def test_setup_core_push_timezone( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert aioclient_mock.mock_calls[2][2]["timezone"] == "testzone" with patch("homeassistant.util.dt.set_default_time_zone"): @@ -442,7 +443,7 @@ async def test_setup_hassio_no_additional_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert aioclient_mock.mock_calls[-1][3]["Authorization"] == "Bearer 123456" @@ -524,14 +525,14 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 26 + assert aioclient_mock.call_count == 24 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 == 28 + assert aioclient_mock.call_count == 26 await hass.services.async_call("hassio", "backup_full", {}) await hass.services.async_call( @@ -546,7 +547,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 30 + assert aioclient_mock.call_count == 28 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "homeassistant": True, @@ -571,7 +572,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 32 + assert aioclient_mock.call_count == 30 assert aioclient_mock.mock_calls[-1][2] == { "addons": ["test"], "folders": ["ssl"], @@ -590,7 +591,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 33 + assert aioclient_mock.call_count == 31 assert aioclient_mock.mock_calls[-1][2] == { "name": "backup_name", "location": "backup_share", @@ -606,7 +607,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 34 + assert aioclient_mock.call_count == 32 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "location": None, @@ -624,7 +625,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 36 + assert aioclient_mock.call_count == 34 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 11:48:00", "location": None, @@ -896,6 +897,7 @@ async def test_coordinator_updates( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + # Initial refresh without stats assert refresh_updates_mock.call_count == 1 with patch( @@ -919,10 +921,12 @@ async def test_coordinator_updates( }, blocking=True, ) - assert refresh_updates_mock.call_count == 1 + assert refresh_updates_mock.call_count == 0 - # There is a 10s cooldown on the debouncer - async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=10)) + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) await hass.async_block_till_done() with patch( @@ -940,6 +944,88 @@ async def test_coordinator_updates( }, blocking=True, ) + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + assert refresh_updates_mock.call_count == 1 + assert "Error on Supervisor API: Unknown" in caplog.text + + +async def test_coordinator_updates_stats_entities_enabled( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + entity_registry_enabled_by_default: None, +) -> None: + """Test coordinator updates with stats entities enabled.""" + await async_setup_component(hass, "homeassistant", {}) + with patch.dict(os.environ, MOCK_ENVIRON), patch( + "homeassistant.components.hassio.HassIO.refresh_updates" + ) as refresh_updates_mock: + 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() + # Initial refresh without stats + assert refresh_updates_mock.call_count == 1 + + # Refresh with stats once we know which ones are needed + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + assert refresh_updates_mock.call_count == 2 + + with patch( + "homeassistant.components.hassio.HassIO.refresh_updates", + ) as refresh_updates_mock: + async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) + await hass.async_block_till_done() + assert refresh_updates_mock.call_count == 0 + + with patch( + "homeassistant.components.hassio.HassIO.refresh_updates", + ) as refresh_updates_mock: + await hass.services.async_call( + "homeassistant", + "update_entity", + { + "entity_id": [ + "update.home_assistant_core_update", + "update.home_assistant_supervisor_update", + ] + }, + blocking=True, + ) + assert refresh_updates_mock.call_count == 0 + + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.hassio.HassIO.refresh_updates", + side_effect=HassioAPIError("Unknown"), + ) as refresh_updates_mock: + await hass.services.async_call( + "homeassistant", + "update_entity", + { + "entity_id": [ + "update.home_assistant_core_update", + "update.home_assistant_supervisor_update", + ] + }, + blocking=True, + ) + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() assert refresh_updates_mock.call_count == 1 assert "Error on Supervisor API: Unknown" in caplog.text @@ -973,7 +1059,7 @@ async def test_setup_hardware_integration( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 817bf871fef..fbc6f08a1f5 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.hassio import ( HASSIO_UPDATE_INTERVAL, HassioAPIError, ) +from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -245,6 +246,12 @@ async def test_sensor( await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + # Verify that the entity have the expected state. state = hass.states.get(entity_id) assert state.state == expected @@ -306,6 +313,12 @@ async def test_stats_addon_sensor( await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + # Verify that the entity have the expected state. state = hass.states.get(entity_id) assert state.state == expected diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 3f12874ef52..42918b02266 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -1,16 +1,18 @@ """The tests for the hassio update entities.""" +from datetime import timedelta import os from unittest.mock import patch import pytest -from homeassistant.components.hassio import DOMAIN -from homeassistant.components.hassio.handler import HassioAPIError +from homeassistant.components.hassio import DOMAIN, HassioAPIError +from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator @@ -609,8 +611,13 @@ async def test_setting_up_core_update_when_addon_fails( await hass.async_block_till_done() assert result + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + # Verify that the core update entity does exist state = hass.states.get("update.home_assistant_core_update") assert state assert state.state == "on" - assert "Could not fetch stats for test: add-on is not running" in caplog.text