Compare commits

...

4 Commits

Author SHA1 Message Date
Mike Degatano 308f214eaf Cancel async_call_laters in SupervisorIssues and fix loaded check in websocket API 2026-05-04 19:08:11 +00:00
Mike Degatano 648dfe832f Prek checks 2026-05-04 19:08:11 +00:00
Mike Degatano 0fa542b806 Address Copilot review feedback on hassio refactor
- Guard websocket_update_config_info and websocket_update_config_update
  against missing DATA_CONFIG_STORE (KeyError when entry in SETUP_RETRY)
- Fix docstring on async_setup_ingress_view in ingress.py
- Add SupervisorIssues.unload() to disconnect the dispatcher listener;
  call it in async_unload_entry to prevent listener accumulation on reload
- Move DATA_KEY_SUPERVISOR_ISSUES creation and async_set_stop_handler
  install to after all coordinator first-refreshes; restore the previous
  stop handler when the entry unloads
- Update test_service_calls_core to use all_setup_requests since the
  stop handler is now installed only after coordinator succeeds
- Add tests: websocket not_loaded guard, SupervisorIssues.unload(),
  stop handler restored on unload, issues not set on coordinator failure

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-04 19:08:06 +00:00
Mike Degatano 8e437ce39d refactor(hassio): move async_setup logic to async_setup_entry; register API surfaces in async_setup
Move all per-entry setup logic from async_setup to async_setup_entry so
that supervisor unavailability triggers ConfigEntryNotReady retry instead
of silent partial setup.

async_setup now registers all static API surfaces (registered once, exist
even when the config entry is retrying):
- Websocket API commands
- HassIOView HTTP proxy
- Discovery, auth, and ingress HTTP views
- Services/actions (per action-setup quality scale rule)
- Frontend panel

async_setup_entry handles everything requiring a live supervisor connection:
- Supervisor ping (raises ConfigEntryNotReady on failure)
- Config store load and auth user setup
- Coordinator first refresh (raises ConfigEntryNotReady on failure)
- Addon panels, push_config listener, stop handler

Additional changes:
- auth.py: HassIOBaseAuth no longer takes User at construction time;
  looks up DATA_HASSIO_SUPERVISOR_USER from hass.data at request time,
  returning 503 if the entry has not loaded yet
- ingress.py: async_setup_ingress_view reads host from hass.data instead
  of taking it as a parameter
- services.py: async_setup_services calls get_supervisor_client(hass)
  internally instead of receiving the client as a parameter; add
  homeassistant.components.hassio.services patch to supervisor_client fixture
- const.py: add DATA_HASSIO_HTTP_CONFIG, DATA_HASSIO_HOST,
  DATA_HASSIO_SUPERVISOR_USER HassKey constants
- coordinator.py: fix is_hass_os initialization (set False in __init__,
  computed from live data in _async_update_data)
- strings.json: add supervisor_not_connected exception translation key
- Remove redundant update_info_data() function (coordinator already
  fetches all info keys)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-04 19:07:09 +00:00
14 changed files with 412 additions and 287 deletions
+159 -208
View File
@@ -6,20 +6,12 @@ from datetime import datetime
import logging
import os
import struct
from typing import Any, cast
from typing import Any
from aiohasupervisor import SupervisorError
from aiohasupervisor.models import (
GreenOptions,
HomeAssistantInfo,
HomeAssistantOptions,
HostInfo,
InstalledAddon,
NetworkInfo,
OSInfo,
RootInfo,
StoreInfo,
SupervisorInfo,
SupervisorOptions,
YellowOptions,
)
@@ -28,6 +20,7 @@ from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.models import RefreshToken
from homeassistant.components import frontend
from homeassistant.components.homeassistant import async_set_stop_handler
from homeassistant.components.homeassistant.const import DATA_STOP_HANDLER
from homeassistant.components.http import (
CONF_SERVER_HOST,
CONF_SERVER_PORT,
@@ -41,6 +34,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import Event, HassJob, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
@@ -51,7 +45,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.issue_registry import IssueSeverity
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.async_ import create_eager_task
# config_flow, diagnostics, system_health, and entity platforms are imported to
# ensure other dependencies that wait for hassio are not waiting
@@ -74,17 +67,12 @@ from .auth import async_setup_auth_view
from .config import HassioConfig
from .const import (
ADDONS_COORDINATOR,
DATA_ADDONS_LIST,
DATA_COMPONENT,
DATA_CONFIG_STORE,
DATA_CORE_INFO,
DATA_HOST_INFO,
DATA_INFO,
DATA_HASSIO_HOST,
DATA_HASSIO_HTTP_CONFIG,
DATA_HASSIO_SUPERVISOR_USER,
DATA_KEY_SUPERVISOR_ISSUES,
DATA_NETWORK_INFO,
DATA_OS_INFO,
DATA_STORE,
DATA_SUPERVISOR_INFO,
DOMAIN,
HASSIO_MAIN_UPDATE_INTERVAL,
MAIN_COORDINATOR,
@@ -188,6 +176,61 @@ def hostname_from_addon_slug(addon_slug: str) -> str:
return addon_slug.replace("_", "-")
@callback
def _check_deprecated_setup(hass: HomeAssistant) -> None:
"""Create issues for deprecated installation types and architectures."""
os_info = get_os_info(hass)
info = get_info(hass)
if os_info is None or info is None:
return
is_haos = info.get("hassos") is not None
board = os_info.get("board")
arch = info.get("arch", "unknown")
unsupported_board = board in {"tinker", "odroid-xu4", "rpi2"}
unsupported_os_on_board = board in {"rpi3", "rpi4"}
if is_haos and (unsupported_board or unsupported_os_on_board):
issue_id = "deprecated_os_"
if unsupported_os_on_board:
issue_id += "aarch64"
elif unsupported_board:
issue_id += "armv7"
ir.async_create_issue(
hass,
"homeassistant",
issue_id,
learn_more_url=DEPRECATION_URL,
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key=issue_id,
translation_placeholders={
"installation_guide": "https://www.home-assistant.io/installation/",
},
)
bit32 = _is_32_bit()
deprecated_architecture = bit32 and not (
unsupported_board or unsupported_os_on_board
)
if not is_haos or deprecated_architecture:
issue_id = "deprecated"
if not is_haos:
issue_id += "_method"
if deprecated_architecture:
issue_id += "_architecture"
ir.async_create_issue(
hass,
"homeassistant",
issue_id,
learn_more_url=DEPRECATION_URL,
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key=issue_id,
translation_placeholders={
"installation_type": "OS" if is_haos else "Supervised",
"arch": arch,
},
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Hass.io component."""
# Check local setup
@@ -201,18 +244,37 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
return False
async_load_websocket_api(hass)
frontend.async_register_built_in_panel(hass, "app")
host = os.environ["SUPERVISOR"]
websession = async_get_clientsession(hass)
hass.data[DATA_COMPONENT] = HassIO(hass.loop, websession, host)
hass.data[DATA_HASSIO_HOST] = host
hass.data[DATA_HASSIO_HTTP_CONFIG] = config.get("http", {})
async_load_websocket_api(hass)
hass.http.register_view(HassIOView(host, websession))
async_setup_services(hass)
async_setup_discovery_view(hass)
async_setup_auth_view(hass)
async_setup_ingress_view(hass)
frontend.async_register_built_in_panel(hass, "app")
discovery_flow.async_create_flow(
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
supervisor_client = get_supervisor_client(hass)
try:
await supervisor_client.supervisor.ping()
except SupervisorError:
_LOGGER.warning("Not connected with the supervisor / system too busy!")
except SupervisorError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="supervisor_not_connected",
) from err
# Load the store
config_store = HassioConfig(hass)
@@ -240,34 +302,52 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
refresh_token = await hass.auth.async_create_refresh_token(user)
config_store.update(hassio_user=user.id)
hass.http.register_view(HassIOView(host, websession))
assert user is not None
hass.data[DATA_HASSIO_SUPERVISOR_USER] = user
async def update_hass_api(http_config: dict[str, Any], refresh_token: RefreshToken):
"""Update Home Assistant API data on Hass.io."""
options = HomeAssistantOptions(
ssl=CONF_SSL_CERTIFICATE in http_config,
port=http_config.get(CONF_SERVER_PORT) or SERVER_PORT,
refresh_token=refresh_token.token,
)
# Set up coordinators — these can raise ConfigEntryNotReady.
# Register listeners only after all refreshes succeed to avoid accumulation
# across retries.
dev_reg = dr.async_get(hass)
if http_config.get(CONF_SERVER_HOST) is not None:
options = replace(options, watchdog=False)
_LOGGER.warning(
"Found incompatible HTTP option 'server_host'. Watchdog feature"
" disabled"
)
coordinator = HassioMainDataUpdateCoordinator(hass, entry, dev_reg)
await coordinator.async_config_entry_first_refresh()
hass.data[MAIN_COORDINATOR] = coordinator
try:
await supervisor_client.homeassistant.set_options(options)
except SupervisorError as err:
_LOGGER.warning(
"Failed to update Home Assistant options in Supervisor: %s", err
)
update_hass_api_task = hass.async_create_task(
update_hass_api(config.get("http", {}), refresh_token), eager_start=True
addon_coordinator = HassioAddOnDataUpdateCoordinator(
hass, entry, dev_reg, coordinator.jobs
)
await addon_coordinator.async_config_entry_first_refresh()
hass.data[ADDONS_COORDINATOR] = addon_coordinator
stats_coordinator = HassioStatsDataUpdateCoordinator(hass, entry)
await stats_coordinator.async_config_entry_first_refresh()
hass.data[STATS_COORDINATOR] = stats_coordinator
# All coordinators refreshed successfully. Start the issues listener and
# install the stop handler now so they are never left in a partial state
# if a coordinator refresh raises ConfigEntryNotReady.
hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass)
async def _async_stop(hass: HomeAssistant, restart: bool) -> None:
"""Stop or restart home assistant."""
if restart:
await supervisor_client.homeassistant.restart()
else:
await supervisor_client.homeassistant.stop()
# Install a custom handler for the homeassistant.restart / stop services,
# and restore the previous one when this entry unloads.
prev_stop_handler = hass.data.get(DATA_STOP_HANDLER)
async_set_stop_handler(hass, _async_stop)
def _restore_stop_handler() -> None:
if prev_stop_handler is not None:
async_set_stop_handler(hass, prev_stop_handler)
else:
hass.data.pop(DATA_STOP_HANDLER, None)
entry.async_on_unload(_restore_stop_handler)
last_timezone = None
last_country = None
@@ -290,103 +370,39 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
except SupervisorError as err:
_LOGGER.warning("Failed to update Supervisor options: %s", err)
hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, push_config)
entry.async_on_unload(hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, push_config))
push_config_task = hass.async_create_task(push_config(None), eager_start=True)
# Start listening for problems with supervisor and making issues
hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass)
issues_task = hass.async_create_task(issues.setup(), eager_start=True)
http_config: dict[str, Any] = hass.data.get(DATA_HASSIO_HTTP_CONFIG, {})
# Register services
async_setup_services(hass, supervisor_client)
async def update_hass_api(refresh_token: RefreshToken) -> None:
"""Update Home Assistant API data on Hass.io."""
options = HomeAssistantOptions(
ssl=CONF_SSL_CERTIFICATE in http_config,
port=http_config.get(CONF_SERVER_PORT) or SERVER_PORT,
refresh_token=refresh_token.token,
)
async def update_info_data(_: datetime | None = None) -> None:
"""Update last available supervisor information."""
supervisor_client = get_supervisor_client(hass)
try:
(
root_info,
host_info,
store_info,
homeassistant_info,
supervisor_info,
os_info,
network_info,
addons_list,
) = cast(
tuple[
RootInfo,
HostInfo,
StoreInfo,
HomeAssistantInfo,
SupervisorInfo,
OSInfo,
NetworkInfo,
list[InstalledAddon],
],
await asyncio.gather(
create_eager_task(supervisor_client.info()),
create_eager_task(supervisor_client.host.info()),
create_eager_task(supervisor_client.store.info()),
create_eager_task(supervisor_client.homeassistant.info()),
create_eager_task(supervisor_client.supervisor.info()),
create_eager_task(supervisor_client.os.info()),
create_eager_task(supervisor_client.network.info()),
create_eager_task(supervisor_client.addons.list()),
),
if http_config.get(CONF_SERVER_HOST) is not None:
options = replace(options, watchdog=False)
_LOGGER.warning(
"Found incompatible HTTP option 'server_host'. Watchdog feature"
" disabled"
)
try:
await supervisor_client.homeassistant.set_options(options)
except SupervisorError as err:
_LOGGER.warning("Can't read Supervisor data: %s", err)
else:
hass.data[DATA_INFO] = root_info
hass.data[DATA_HOST_INFO] = host_info
hass.data[DATA_STORE] = store_info
hass.data[DATA_CORE_INFO] = homeassistant_info
hass.data[DATA_SUPERVISOR_INFO] = supervisor_info
hass.data[DATA_OS_INFO] = os_info
hass.data[DATA_NETWORK_INFO] = network_info
hass.data[DATA_ADDONS_LIST] = addons_list
_LOGGER.warning(
"Failed to update Home Assistant options in Supervisor: %s", err
)
# Fetch data
update_info_task = hass.async_create_task(update_info_data(), eager_start=True)
async def _async_stop(hass: HomeAssistant, restart: bool) -> None:
"""Stop or restart home assistant."""
if restart:
await supervisor_client.homeassistant.restart()
else:
await supervisor_client.homeassistant.stop()
# Set a custom handler for the homeassistant.restart and homeassistant.stop services
async_set_stop_handler(hass, _async_stop)
# Init discovery Hass.io feature
async_setup_discovery_view(hass)
# Init auth Hass.io feature
assert user is not None
async_setup_auth_view(hass, user)
# Init ingress Hass.io feature
async_setup_ingress_view(hass, host)
# Init add-on ingress panels
panels_task = hass.async_create_task(
async_setup_addon_panel(hass), eager_start=True
await asyncio.gather(
update_hass_api(refresh_token),
push_config(None),
issues.setup(),
async_setup_addon_panel(hass),
)
# Make sure to await the update_info task before
# _async_setup_hardware_integration is called
# so the hardware integration can be set up
# and does not fallback to calling later
await update_hass_api_task
await panels_task
await update_info_task
await push_config_task
await issues_task
# Setup hardware integration for the detected board type
@callback
def _async_setup_hardware_integration(_: datetime | None = None) -> None:
@@ -412,81 +428,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
_async_setup_hardware_integration()
discovery_flow.async_create_flow(
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
dev_reg = dr.async_get(hass)
coordinator = HassioMainDataUpdateCoordinator(hass, entry, dev_reg)
await coordinator.async_config_entry_first_refresh()
hass.data[MAIN_COORDINATOR] = coordinator
addon_coordinator = HassioAddOnDataUpdateCoordinator(
hass, entry, dev_reg, coordinator.jobs
)
await addon_coordinator.async_config_entry_first_refresh()
hass.data[ADDONS_COORDINATOR] = addon_coordinator
stats_coordinator = HassioStatsDataUpdateCoordinator(hass, entry)
await stats_coordinator.async_config_entry_first_refresh()
hass.data[STATS_COORDINATOR] = stats_coordinator
def deprecated_setup_issue() -> None:
os_info = get_os_info(hass)
info = get_info(hass)
if os_info is None or info is None:
return
is_haos = info.get("hassos") is not None
board = os_info.get("board")
arch = info.get("arch", "unknown")
unsupported_board = board in {"tinker", "odroid-xu4", "rpi2"}
unsupported_os_on_board = board in {"rpi3", "rpi4"}
if is_haos and (unsupported_board or unsupported_os_on_board):
issue_id = "deprecated_os_"
if unsupported_os_on_board:
issue_id += "aarch64"
elif unsupported_board:
issue_id += "armv7"
ir.async_create_issue(
hass,
"homeassistant",
issue_id,
learn_more_url=DEPRECATION_URL,
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key=issue_id,
translation_placeholders={
"installation_guide": "https://www.home-assistant.io/installation/",
},
)
bit32 = _is_32_bit()
deprecated_architecture = bit32 and not (
unsupported_board or unsupported_os_on_board
)
if not is_haos or deprecated_architecture:
issue_id = "deprecated"
if not is_haos:
issue_id += "_method"
if deprecated_architecture:
issue_id += "_architecture"
ir.async_create_issue(
hass,
"homeassistant",
issue_id,
learn_more_url=DEPRECATION_URL,
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key=issue_id,
translation_placeholders={
"installation_type": "OS" if is_haos else "Supervised",
"arch": arch,
},
)
_check_deprecated_setup(hass)
listener()
listener = coordinator.async_add_listener(deprecated_setup_issue)
@@ -504,9 +448,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator: HassioMainDataUpdateCoordinator = hass.data[MAIN_COORDINATOR]
coordinator.unload()
# Pop coordinators
# Pop coordinators and entry-level data
hass.data.pop(MAIN_COORDINATOR, None)
hass.data.pop(ADDONS_COORDINATOR, None)
hass.data.pop(STATS_COORDINATOR, None)
hass.data.pop(DATA_CONFIG_STORE, None)
hass.data.pop(DATA_HASSIO_SUPERVISOR_USER, None)
if (
supervisor_issues := hass.data.pop(DATA_KEY_SUPERVISOR_ISSUES, None)
) is not None:
supervisor_issues.unload()
return unload_ok
+15 -12
View File
@@ -6,41 +6,44 @@ import logging
import os
from aiohttp import web
from aiohttp.web_exceptions import HTTPNotFound, HTTPUnauthorized
from aiohttp.web_exceptions import (
HTTPNotFound,
HTTPServiceUnavailable,
HTTPUnauthorized,
)
import voluptuous as vol
from homeassistant.auth.models import User
from homeassistant.auth.providers import homeassistant as auth_ha
from homeassistant.components.http import KEY_HASS, KEY_HASS_USER, HomeAssistantView
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME
from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME, DATA_HASSIO_SUPERVISOR_USER
_LOGGER = logging.getLogger(__name__)
@callback
def async_setup_auth_view(hass: HomeAssistant, user: User) -> None:
def async_setup_auth_view(hass: HomeAssistant) -> None:
"""Auth setup."""
hassio_auth = HassIOAuth(hass, user)
hassio_password_reset = HassIOPasswordReset(hass, user)
hass.http.register_view(hassio_auth)
hass.http.register_view(hassio_password_reset)
hass.http.register_view(HassIOAuth(hass))
hass.http.register_view(HassIOPasswordReset(hass))
class HassIOBaseAuth(HomeAssistantView):
"""Hass.io view to handle auth requests."""
def __init__(self, hass: HomeAssistant, user: User) -> None:
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize WebView."""
self.hass = hass
self.user = user
def _check_access(self, request: web.Request) -> None:
"""Check if this call is from Supervisor."""
user = self.hass.data.get(DATA_HASSIO_SUPERVISOR_USER)
if user is None:
raise HTTPServiceUnavailable
# Check caller IP
hassio_ip = os.environ["SUPERVISOR"].split(":")[0]
assert request.transport
@@ -51,7 +54,7 @@ class HassIOBaseAuth(HomeAssistantView):
raise HTTPUnauthorized
# Check caller token
if request[KEY_HASS_USER].id != self.user.id:
if request[KEY_HASS_USER].id != user.id:
_LOGGER.error("Invalid auth request from %s", request[KEY_HASS_USER].name)
raise HTTPUnauthorized
+6 -1
View File
@@ -2,7 +2,7 @@
from datetime import timedelta
from enum import StrEnum
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from homeassistant.util.hass_dict import HassKey
@@ -20,6 +20,8 @@ if TYPE_CHECKING:
SupervisorInfo,
)
from homeassistant.auth.models import User
from .config import HassioConfig
from .coordinator import (
HassioAddOnDataUpdateCoordinator,
@@ -145,6 +147,9 @@ DATA_KEY_CORE = "core"
DATA_KEY_HOST = "host"
DATA_KEY_SUPERVISOR_ISSUES: HassKey[SupervisorIssues] = HassKey("supervisor_issues")
DATA_KEY_MOUNTS = "mounts"
DATA_HASSIO_HTTP_CONFIG: HassKey[dict[str, Any]] = HassKey("hassio_http_config")
DATA_HASSIO_HOST: HassKey[str] = HassKey("hassio_host")
DATA_HASSIO_SUPERVISOR_USER: HassKey[User] = HassKey("hassio_supervisor_user")
PLACEHOLDER_KEY_ADDON = "addon"
PLACEHOLDER_KEY_ADDON_INFO = "addon_info"
@@ -780,10 +780,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
)
self.entry_id = config_entry.entry_id
self.dev_reg = dev_reg
if info := self.hass.data.get(DATA_INFO):
self.is_hass_os = info.hassos is not None
else:
self.is_hass_os = False
self.is_hass_os = False
self.supervisor_client = get_supervisor_client(hass)
self.jobs = SupervisorJobs(hass)
self._dispatcher_disconnect = async_dispatcher_connect(
@@ -843,6 +840,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
raise UpdateFailed(f"Error on Supervisor API: {err}") from err
# Build clean coordinator data
self.is_hass_os = info.hassos is not None
new_data = HassioMainData(
core=core_info,
supervisor=supervisor_info,
+4 -3
View File
@@ -20,7 +20,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import UNDEFINED
from homeassistant.util.async_ import create_eager_task
from .const import X_HASS_SOURCE, X_INGRESS_PATH
from .const import DATA_HASSIO_HOST, X_HASS_SOURCE, X_INGRESS_PATH
from .http import should_compress
_LOGGER = logging.getLogger(__name__)
@@ -50,8 +50,9 @@ DISABLED_TIMEOUT = ClientTimeout(total=None)
@callback
def async_setup_ingress_view(hass: HomeAssistant, host: str) -> None:
"""Auth setup."""
def async_setup_ingress_view(hass: HomeAssistant) -> None:
"""Set up the Hass.io ingress HTTP view."""
host = hass.data[DATA_HASSIO_HOST]
websession = async_get_clientsession(hass)
hassio_ingress = HassIOIngress(host, websession)
+15 -2
View File
@@ -1,6 +1,7 @@
"""Supervisor events monitor."""
import asyncio
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime
import logging
@@ -180,6 +181,8 @@ class SupervisorIssues:
self._unhealthy_reasons: set[str] = set()
self._issues: dict[UUID, Issue] = {}
self._supervisor_client = get_supervisor_client(hass)
self._disconnect: Callable[[], None] | None = None
self._cancel_update_retry: Callable[[], None] | None = None
@property
def unhealthy_reasons(self) -> set[str]:
@@ -352,22 +355,32 @@ class SupervisorIssues:
"""Create supervisor events listener."""
await self._update()
async_dispatcher_connect(
self._disconnect = async_dispatcher_connect(
self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_issues
)
def unload(self) -> None:
"""Remove supervisor events listener."""
if self._disconnect is not None:
self._disconnect()
self._disconnect = None
if self._cancel_update_retry is not None:
self._cancel_update_retry()
self._cancel_update_retry = None
async def _update(self, _: datetime | None = None) -> None:
"""Update issues from Supervisor resolution center."""
try:
data = await self._supervisor_client.resolution.info()
except SupervisorError as err:
_LOGGER.error("Failed to update supervisor issues: %r", err)
async_call_later(
self._cancel_update_retry = async_call_later(
self._hass,
REQUEST_REFRESH_DELAY,
HassJob(self._update, cancel_on_shutdown=True),
)
return
self._cancel_update_retry = None
self.unhealthy_reasons = set(data.unhealthy)
self.unsupported_reasons = set(data.unsupported)
+3 -3
View File
@@ -50,6 +50,7 @@ from .const import (
SupervisorEntityModel,
)
from .coordinator import HassioMainDataUpdateCoordinator, get_addons_info
from .handler import get_supervisor_client
SERVICE_ADDON_START = "addon_start"
SERVICE_ADDON_STOP = "addon_stop"
@@ -163,10 +164,9 @@ SCHEMA_MOUNT_RELOAD = vol.Schema(
@callback
def async_setup_services(
hass: HomeAssistant, supervisor_client: SupervisorClient
) -> None:
def async_setup_services(hass: HomeAssistant) -> None:
"""Register the Supervisor services."""
supervisor_client = get_supervisor_client(hass)
async_register_app_services(hass, supervisor_client)
async_register_host_services(hass, supervisor_client)
async_register_backup_restore_services(hass, supervisor_client)
@@ -52,6 +52,9 @@
},
"mount_reload_unknown_device_id": {
"message": "Device ID not found"
},
"supervisor_not_connected": {
"message": "Not connected with the supervisor / system too busy"
}
},
"issues": {
@@ -31,6 +31,7 @@ from .const import (
ATTR_WS_EVENT,
DATA_COMPONENT,
DATA_CONFIG_STORE,
DOMAIN,
EVENT_SUPERVISOR_EVENT,
WS_ID,
WS_TYPE,
@@ -209,9 +210,13 @@ def websocket_update_config_info(
msg: dict[str, Any],
) -> None:
"""Send the stored backup config."""
connection.send_result(
msg["id"], hass.data[DATA_CONFIG_STORE].data.update_config.to_dict()
)
if (
not hass.config_entries.async_loaded_entries(DOMAIN)
or (config_store := hass.data.get(DATA_CONFIG_STORE)) is None
):
connection.send_error(msg["id"], "not_loaded", "Supervisor not loaded")
return
connection.send_result(msg["id"], config_store.data.update_config.to_dict())
@callback
@@ -230,10 +235,14 @@ def websocket_update_config_update(
msg: dict[str, Any],
) -> None:
"""Update the stored backup config."""
if (
not hass.config_entries.async_loaded_entries(DOMAIN)
or (config_store := hass.data.get(DATA_CONFIG_STORE)) is None
):
connection.send_error(msg["id"], "not_loaded", "Supervisor not loaded")
return
changes = dict(msg)
changes.pop("id")
changes.pop("type")
hass.data[DATA_CONFIG_STORE].update(
update_config=cast(HassioUpdateParametersDict, changes)
)
config_store.update(update_config=cast(HassioUpdateParametersDict, changes))
connection.send_result(msg["id"])
+4
View File
@@ -872,6 +872,10 @@ def supervisor_client() -> Generator[AsyncMock]:
"homeassistant.components.hassio.repairs.get_supervisor_client",
return_value=supervisor_client,
),
patch(
"homeassistant.components.hassio.services.get_supervisor_client",
return_value=supervisor_client,
),
patch(
"homeassistant.components.hassio.update_helper.get_supervisor_client",
return_value=supervisor_client,
+28 -23
View File
@@ -1,6 +1,7 @@
"""Test add-on panel."""
from http import HTTPStatus
import os
from unittest.mock import AsyncMock, patch
from aiohasupervisor.models import IngressPanel
@@ -12,17 +13,15 @@ from homeassistant.setup import async_setup_component
from tests.common import MockUser
from tests.typing import ClientSessionGenerator
MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"}
@pytest.fixture(autouse=True)
def mock_all(
supervisor_is_connected: AsyncMock,
homeassistant_info: AsyncMock,
ingress_panels: AsyncMock,
) -> None:
def mock_all(all_setup_requests: None) -> None:
"""Mock all setup requests."""
@pytest.mark.usefixtures("hassio_env")
@pytest.mark.usefixtures("supervisor_client")
async def test_hassio_addon_panel_startup(
hass: HomeAssistant, ingress_panels: AsyncMock
) -> None:
@@ -37,8 +36,9 @@ async def test_hassio_addon_panel_startup(
with patch(
"homeassistant.components.hassio.addon_panel._register_panel",
) as mock_panel:
await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
with patch.dict(os.environ, MOCK_ENVIRON):
await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
ingress_panels.assert_called_once()
assert mock_panel.called
@@ -49,7 +49,7 @@ async def test_hassio_addon_panel_startup(
)
@pytest.mark.usefixtures("hassio_env")
@pytest.mark.usefixtures("supervisor_client")
async def test_hassio_addon_panel_api(
hass: HomeAssistant, hass_client: ClientSessionGenerator, ingress_panels: AsyncMock
) -> None:
@@ -64,8 +64,9 @@ async def test_hassio_addon_panel_api(
with patch(
"homeassistant.components.hassio.addon_panel._register_panel",
) as mock_panel:
await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
with patch.dict(os.environ, MOCK_ENVIRON):
await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
ingress_panels.assert_called_once()
assert mock_panel.called
@@ -91,7 +92,7 @@ async def test_hassio_addon_panel_api(
)
@pytest.mark.usefixtures("hassio_env")
@pytest.mark.usefixtures("supervisor_client")
async def test_hassio_addon_panel_api_non_admin(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
@@ -106,8 +107,9 @@ async def test_hassio_addon_panel_api_non_admin(
with patch(
"homeassistant.components.hassio.addon_panel._register_panel",
) as mock_panel:
await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
with patch.dict(os.environ, MOCK_ENVIRON):
await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
ingress_panels.assert_called_once()
mock_panel.assert_called_once()
@@ -127,7 +129,7 @@ async def test_hassio_addon_panel_api_non_admin(
mock_panel.assert_not_called()
@pytest.mark.usefixtures("hassio_env")
@pytest.mark.usefixtures("supervisor_client")
async def test_hassio_addon_panel_registration(
hass: HomeAssistant, ingress_panels: AsyncMock
) -> None:
@@ -141,8 +143,9 @@ async def test_hassio_addon_panel_registration(
with patch(
"homeassistant.components.hassio.addon_panel.frontend.async_register_built_in_panel"
) as mock_register:
await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
with patch.dict(os.environ, MOCK_ENVIRON):
await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
# Verify that async_register_built_in_panel was called with correct arguments
# for our test addon
@@ -157,7 +160,7 @@ async def test_hassio_addon_panel_registration(
)
@pytest.mark.usefixtures("hassio_env")
@pytest.mark.usefixtures("supervisor_client")
async def test_hassio_addon_panel_api_delete(
hass: HomeAssistant, hass_client: ClientSessionGenerator, ingress_panels: AsyncMock
) -> None:
@@ -165,8 +168,9 @@ async def test_hassio_addon_panel_api_delete(
ingress_panels.return_value = {
"test1": IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False),
}
await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
with patch.dict(os.environ, MOCK_ENVIRON):
await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
hass_client = await hass_client()
@@ -178,7 +182,7 @@ async def test_hassio_addon_panel_api_delete(
mock_remove.assert_called_once_with(hass, "test1")
@pytest.mark.usefixtures("hassio_env")
@pytest.mark.usefixtures("supervisor_client")
async def test_hassio_addon_panel_api_delete_non_admin(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
@@ -189,8 +193,9 @@ async def test_hassio_addon_panel_api_delete_non_admin(
ingress_panels.return_value = {
"test1": IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False),
}
await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
with patch.dict(os.environ, MOCK_ENVIRON):
await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
hass_admin_user.groups = []
hass_client = await hass_client()
+79 -24
View File
@@ -53,6 +53,7 @@ from homeassistant.components.hassio import (
)
from homeassistant.components.hassio.config import STORAGE_KEY
from homeassistant.components.hassio.const import (
DATA_KEY_SUPERVISOR_ISSUES,
HASSIO_MAIN_UPDATE_INTERVAL,
REQUEST_REFRESH_DELAY,
)
@@ -60,7 +61,9 @@ from homeassistant.components.homeassistant import (
DOMAIN as HOMEASSISTANT_DOMAIN,
SERVICE_UPDATE_ENTITY,
)
from homeassistant.components.homeassistant.const import DATA_STOP_HANDLER
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import device_registry as dr, issue_registry as ir
@@ -166,11 +169,28 @@ async def test_setup_api_ping(
await hass.async_block_till_done()
assert result
assert len(supervisor_client.mock_calls) == 25
assert len(supervisor_client.mock_calls) == 17
assert get_core_info(hass)["version_latest"] == "1.0.0"
assert is_hassio(hass)
async def test_setup_api_ping_fails(
hass: HomeAssistant, supervisor_client: AsyncMock
) -> None:
"""Test that a failed ping raises ConfigEntryNotReady and retries."""
supervisor_client.supervisor.ping.side_effect = SupervisorError
with patch.dict(os.environ, MOCK_ENVIRON):
result = await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
# async_setup succeeds (domain registered), but the config entry is in retry
assert result
assert is_hassio(hass)
entry = hass.config_entries.async_entries("hassio")[0]
assert entry.state is ConfigEntryState.SETUP_RETRY
async def test_setup_app_panel(hass: HomeAssistant) -> None:
"""Test app panel is registered."""
with patch.dict(os.environ, MOCK_ENVIRON):
@@ -204,7 +224,7 @@ async def test_setup_api_push_api_data(
await hass.async_block_till_done()
assert result
assert len(supervisor_client.mock_calls) == 25
assert len(supervisor_client.mock_calls) == 17
supervisor_client.homeassistant.set_options.assert_called_once_with(
HomeAssistantOptions(ssl=False, port=9999, refresh_token=ANY)
)
@@ -220,7 +240,7 @@ async def test_setup_api_push_api_data_error(
await hass.async_block_till_done()
assert result
assert len(supervisor_client.mock_calls) == 25
assert len(supervisor_client.mock_calls) == 17
assert "Failed to update Home Assistant options in Supervisor: boom" in caplog.text
@@ -237,7 +257,7 @@ async def test_setup_api_push_api_data_server_host(
await hass.async_block_till_done()
assert result
assert len(supervisor_client.mock_calls) == 25
assert len(supervisor_client.mock_calls) == 17
supervisor_client.homeassistant.set_options.assert_called_once_with(
HomeAssistantOptions(ssl=False, port=9999, refresh_token=ANY, watchdog=False)
)
@@ -255,7 +275,7 @@ async def test_setup_api_push_api_data_default(
await hass.async_block_till_done()
assert result
assert len(supervisor_client.mock_calls) == 25
assert len(supervisor_client.mock_calls) == 17
supervisor_client.homeassistant.set_options.assert_called_once_with(
HomeAssistantOptions(ssl=False, port=8123, refresh_token=ANY)
)
@@ -332,7 +352,7 @@ async def test_setup_api_existing_hassio_user(
await hass.async_block_till_done()
assert result
assert len(supervisor_client.mock_calls) == 25
assert len(supervisor_client.mock_calls) == 17
supervisor_client.homeassistant.set_options.assert_called_once_with(
HomeAssistantOptions(ssl=False, port=8123, refresh_token=token.token)
)
@@ -349,7 +369,7 @@ async def test_setup_core_push_config(
await hass.async_block_till_done()
assert result
assert len(supervisor_client.mock_calls) == 25
assert len(supervisor_client.mock_calls) == 17
supervisor_client.supervisor.set_options.assert_called_once_with(
SupervisorOptions(timezone="testzone")
)
@@ -374,7 +394,7 @@ async def test_setup_core_push_config_error(
await hass.async_block_till_done()
assert result
assert len(supervisor_client.mock_calls) == 25
assert len(supervisor_client.mock_calls) == 17
assert "Failed to update Supervisor options: boom" in caplog.text
@@ -390,7 +410,7 @@ async def test_setup_hassio_no_additional_data(
await hass.async_block_till_done()
assert result
assert len(supervisor_client.mock_calls) == 25
assert len(supervisor_client.mock_calls) == 17
async def test_fail_setup_without_environ_var(hass: HomeAssistant) -> None:
@@ -402,17 +422,17 @@ async def test_fail_setup_without_environ_var(hass: HomeAssistant) -> None:
async def test_warn_when_cannot_connect(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
supervisor_is_connected: AsyncMock,
) -> None:
"""Fail warn when we cannot connect."""
"""Test that a failed ping puts the config entry in retry state."""
supervisor_is_connected.side_effect = SupervisorError
with patch.dict(os.environ, MOCK_ENVIRON):
result = await async_setup_component(hass, "hassio", {})
assert result
assert is_hassio(hass)
assert "Not connected with the supervisor / system too busy!" in caplog.text
entry = hass.config_entries.async_entries("hassio")[0]
assert entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.usefixtures("hassio_env")
@@ -607,11 +627,8 @@ async def test_service_calls(
"app_or_addon",
["app", "addon"],
)
async def test_invalid_service_calls(
hass: HomeAssistant, supervisor_is_connected: AsyncMock, app_or_addon: str
) -> None:
async def test_invalid_service_calls(hass: HomeAssistant, app_or_addon: str) -> None:
"""Call service with invalid input and check that it raises."""
supervisor_is_connected.side_effect = SupervisorError
with patch.dict(os.environ, MOCK_ENVIRON):
assert await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
@@ -702,25 +719,23 @@ async def test_addon_service_call_with_complex_slug(
)
@pytest.mark.usefixtures("hassio_env")
@pytest.mark.usefixtures("all_setup_requests")
async def test_service_calls_core(
hass: HomeAssistant, supervisor_client: AsyncMock
) -> None:
"""Call core service and check the API calls behind that."""
assert await async_setup_component(hass, "homeassistant", {})
assert await async_setup_component(hass, "hassio", {})
with patch.dict(os.environ, MOCK_ENVIRON):
assert await async_setup_component(hass, "homeassistant", {})
assert await async_setup_component(hass, "hassio", {})
await hass.services.async_call("homeassistant", "stop")
await hass.async_block_till_done()
supervisor_client.homeassistant.stop.assert_called_once_with()
assert len(supervisor_client.mock_calls) == 21
await hass.services.async_call("homeassistant", "check_config")
await hass.async_block_till_done()
assert len(supervisor_client.mock_calls) == 21
with patch(
"homeassistant.config.async_check_ha_config_file", return_value=None
) as mock_check_config:
@@ -729,7 +744,6 @@ async def test_service_calls_core(
assert mock_check_config.called
supervisor_client.homeassistant.restart.assert_called_once_with()
assert len(supervisor_client.mock_calls) == 22
@pytest.mark.parametrize(
@@ -1046,7 +1060,7 @@ async def test_setup_hardware_integration(
await hass.async_block_till_done(wait_background_tasks=True)
assert result
assert len(supervisor_client.mock_calls) == 25
assert len(supervisor_client.mock_calls) == 17
assert len(mock_setup_entry.mock_calls) == 1
@@ -1729,3 +1743,44 @@ async def test_get_core_info(hass: HomeAssistant) -> None:
assert result["version"] == "1.0.0"
assert result["version_latest"] == "1.0.0"
assert result["image"] == "homeassistant"
@pytest.mark.usefixtures("all_setup_requests")
async def test_stop_handler_restored_on_unload(
hass: HomeAssistant,
supervisor_client: AsyncMock,
) -> None:
"""Test that the default stop handler is restored when the hassio entry unloads."""
assert await async_setup_component(hass, "homeassistant", {})
with patch.dict(os.environ, MOCK_ENVIRON):
assert await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
hassio_stop_handler = hass.data[DATA_STOP_HANDLER]
entry = hass.config_entries.async_entries("hassio")[0]
await hass.config_entries.async_unload(entry.entry_id)
# After unload the hassio handler must no longer be registered.
assert hass.data.get(DATA_STOP_HANDLER) is not hassio_stop_handler
@pytest.mark.usefixtures("supervisor_client")
async def test_supervisor_issues_not_set_on_coordinator_failure(
hass: HomeAssistant,
supervisor_is_connected: AsyncMock,
supervisor_root_info: AsyncMock,
) -> None:
"""Test DATA_KEY_SUPERVISOR_ISSUES is not populated when coordinator fails.
If a coordinator first-refresh raises ConfigEntryNotReady the issues
listener must not be registered, preventing accumulation across retries.
"""
supervisor_root_info.side_effect = SupervisorError()
with patch.dict(os.environ, MOCK_ENVIRON):
result = await async_setup_component(hass, "hassio", {})
assert result
entry = hass.config_entries.async_entries("hassio")[0]
assert entry.state is ConfigEntryState.SETUP_RETRY
assert DATA_KEY_SUPERVISOR_ISSUES not in hass.data
+50 -1
View File
@@ -27,8 +27,11 @@ from aiohasupervisor.models import (
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.hassio.const import DATA_HOST_INFO
from homeassistant.components.hassio.issues import SupervisorIssues
from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.setup import async_setup_component
from .test_init import MOCK_ENVIRON
@@ -1109,11 +1112,13 @@ async def test_supervisor_issues_free_space_host_info_fail(
) -> None:
"""Test supervisor issue for too little free space remaining without host info."""
mock_resolution_info(supervisor_client)
host_info.side_effect = SupervisorError()
result = await async_setup_component(hass, "hassio", {})
assert result
# Simulate host info being unavailable after setup (e.g., cached data cleared)
hass.data.pop(DATA_HOST_INFO, None)
client = await hass_supervisor_ws_client()
await client.send_json(
@@ -1206,3 +1211,47 @@ async def test_supervisor_issues_addon_pwned(
"more_info_pwned": "https://www.home-assistant.io/more-info/pwned-passwords",
},
)
async def test_supervisor_issues_unload_disconnects_listener(
hass: HomeAssistant,
supervisor_client: AsyncMock,
resolution_info: AsyncMock,
) -> None:
"""Test SupervisorIssues.unload() disconnects the EVENT_SUPERVISOR_EVENT listener.
After calling unload(), dispatching supervisor events must not trigger
the listener — preventing listener accumulation on config-entry reload.
"""
mock_resolution_info(supervisor_client)
issues = SupervisorIssues(hass)
await issues.setup()
# While connected, a health_changed event updates unhealthy_reasons.
async_dispatcher_send(
hass,
"supervisor_event",
{
"event": "health_changed",
"data": {"healthy": False, "unhealthy_reasons": ["docker"]},
},
)
await hass.async_block_till_done()
assert "docker" in issues.unhealthy_reasons
# After unload(), the same event is silently ignored.
issues.unload()
async_dispatcher_send(
hass,
"supervisor_event",
{
"event": "health_changed",
"data": {"healthy": True},
},
)
await hass.async_block_till_done()
# unhealthy_reasons unchanged — listener did not fire.
assert "docker" in issues.unhealthy_reasons
# Calling unload() again is safe (idempotent).
issues.unload()
@@ -27,6 +27,7 @@ from homeassistant.components.hassio.const import (
WS_TYPE_API,
WS_TYPE_SUBSCRIBE,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import __version__ as HAVERSION
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -986,3 +987,31 @@ async def test_read_update_config(
await websocket_client.send_json_auto_id({"type": "hassio/update/config/info"})
assert await websocket_client.receive_json() == snapshot
async def test_update_config_not_loaded(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
) -> None:
"""Test update config commands return not_loaded when entry is in SETUP_RETRY."""
supervisor_client.supervisor.ping.side_effect = SupervisorError
with patch.dict(os.environ, MOCK_ENVIRON):
await async_setup_component(hass, "hassio", {})
entry = hass.config_entries.async_entries("hassio")[0]
assert entry.state is ConfigEntryState.SETUP_RETRY
websocket_client = await hass_ws_client(hass)
await websocket_client.send_json_auto_id({"type": "hassio/update/config/info"})
result = await websocket_client.receive_json()
assert not result["success"]
assert result["error"]["code"] == "not_loaded"
await websocket_client.send_json_auto_id(
{"type": "hassio/update/config/update", "add_on_backup_before_update": True}
)
result = await websocket_client.receive_json()
assert not result["success"]
assert result["error"]["code"] == "not_loaded"