From 5780bbcdd85da59c9d7c55e6d137cb02430bb84d Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 1 Apr 2026 19:01:02 +0200 Subject: [PATCH 01/16] Use Unix socket for Supervisor to Core communication Switch internal Supervisor-to-Core HTTP and WebSocket communication from TCP (port 8123) to a Unix domain socket. The existing /run/supervisor directory on the host (already mounted at /run/os inside the Supervisor container) is bind-mounted into the Core container at /run/supervisor. Core receives the socket path via the SUPERVISOR_CORE_API_SOCKET environment variable, creates the socket there, and Supervisor connects to it via aiohttp.UnixConnector at /run/os/core.sock. Since the Unix socket is only reachable by processes on the same host, requests arriving over it are implicitly trusted and authenticated as the existing Supervisor system user. This removes the token round-trip where Supervisor had to obtain and send Bearer tokens on every Core API call. WebSocket connections are likewise authenticated implicitly, skipping the auth_required/auth handshake. Key design decisions: - Version-gated by CORE_UNIX_SOCKET_MIN_VERSION so older Core versions transparently continue using TCP with token auth - LANDINGPAGE is explicitly excluded (not a CalVer version) - Hard-fails with a clear error if the socket file is unexpectedly missing when Unix socket communication is expected - WSClient.connect() for Unix socket (no auth) and WSClient.connect_with_auth() for TCP (token auth) separate the two connection modes cleanly - Token refresh always uses the TCP websession since it is inherently a TCP/Bearer-auth operation - Logs which transport (Unix socket vs TCP) is being used on first request Closes #6626 Related Core PR: home-assistant/core#163907 Co-Authored-By: Claude Opus 4.6 (1M context) --- supervisor/api/proxy.py | 57 ++--------- supervisor/const.py | 3 +- supervisor/core.py | 1 + supervisor/docker/const.py | 10 ++ supervisor/docker/homeassistant.py | 6 ++ supervisor/homeassistant/api.py | 138 +++++++++++++++++++++++--- supervisor/homeassistant/websocket.py | 118 ++++++++++++++-------- tests/addons/test_manager.py | 2 +- tests/api/test_auth.py | 4 +- tests/api/test_discovery.py | 2 +- tests/conftest.py | 4 +- tests/docker/test_homeassistant.py | 24 +++++ tests/homeassistant/test_module.py | 6 +- 13 files changed, 261 insertions(+), 114 deletions(-) diff --git a/supervisor/api/proxy.py b/supervisor/api/proxy.py index b5a04214d62..5b5330f4734 100644 --- a/supervisor/api/proxy.py +++ b/supervisor/api/proxy.py @@ -7,7 +7,6 @@ import aiohttp from aiohttp import WSCloseCode, WSMessageTypeError, web -from aiohttp.client_exceptions import ClientConnectorError from aiohttp.client_ws import ClientWebSocketResponse from aiohttp.hdrs import AUTHORIZATION, CONTENT_TYPE from aiohttp.http_websocket import WSMsgType @@ -179,57 +178,17 @@ async def api(self, request: web.Request): async def _websocket_client(self) -> ClientWebSocketResponse: """Initialize a WebSocket API connection.""" - url = f"{self.sys_homeassistant.api_url}/api/websocket" - try: - client = await self.sys_websession.ws_connect( - url, heartbeat=30, ssl=False, max_msg_size=MAX_MESSAGE_SIZE_FROM_CORE - ) - - # Handle authentication - data = await client.receive_json() - - if data.get("type") == "auth_ok": - return client - - if data.get("type") != "auth_required": - # Invalid protocol - raise APIError( - f"Got unexpected response from Home Assistant WebSocket: {data}", - _LOGGER.error, - ) - - # Auth session - await self.sys_homeassistant.api.ensure_access_token() - await client.send_json( - { - "type": "auth", - "access_token": self.sys_homeassistant.api.access_token, - }, - dumps=json_dumps, + ws_client = await self.sys_homeassistant.api.connect_websocket( + max_msg_size=MAX_MESSAGE_SIZE_FROM_CORE ) - - data = await client.receive_json() - - if data.get("type") == "auth_ok": - return client - - # Renew the Token is invalid - if ( - data.get("type") == "invalid_auth" - and self.sys_homeassistant.refresh_token - ): - self.sys_homeassistant.api.access_token = None - return await self._websocket_client() - - raise HomeAssistantAuthError() - - except (RuntimeError, ValueError, TypeError, ClientConnectorError) as err: + return ws_client.client + except HomeAssistantAPIError as err: + _LOGGER.error("Error connecting to Home Assistant WebSocket: %s", err) + raise APIError() from err + except (RuntimeError, ValueError, TypeError) as err: _LOGGER.error("Client error on WebSocket API %s.", err) - except HomeAssistantAuthError: - _LOGGER.error("Failed authentication to Home Assistant WebSocket") - - raise APIError() + raise APIError() from err async def _proxy_message( self, diff --git a/supervisor/const.py b/supervisor/const.py index 996f32458bd..8a36356e95b 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -39,9 +39,10 @@ FILE_SUFFIX_CONFIGURATION = [".yaml", ".yml", ".json"] MACHINE_ID = Path("/etc/machine-id") +RUN_SUPERVISOR_STATE = Path("/run/supervisor") +SOCKET_CORE = Path("/run/os/core.sock") SOCKET_DBUS = Path("/run/dbus/system_bus_socket") SOCKET_DOCKER = Path("/run/docker.sock") -RUN_SUPERVISOR_STATE = Path("/run/supervisor") SYSTEMD_JOURNAL_PERSISTENT = Path("/var/log/journal") SYSTEMD_JOURNAL_VOLATILE = Path("/run/log/journal") diff --git a/supervisor/core.py b/supervisor/core.py index 1594e679c8f..9cfec29fca1 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -338,6 +338,7 @@ async def stop(self) -> None: self.sys_create_task(coro) for coro in ( self.sys_websession.close(), + self.sys_homeassistant.api.close(), self.sys_ingress.unload(), self.sys_hardware.unload(), self.sys_dbus.unload(), diff --git a/supervisor/docker/const.py b/supervisor/docker/const.py index e043cea1c1a..42ead9cf99d 100644 --- a/supervisor/docker/const.py +++ b/supervisor/docker/const.py @@ -89,6 +89,7 @@ class MountBindOptions: propagation: PropagationMode | None = None read_only_non_recursive: bool | None = None + create_mountpoint: bool | None = None def to_dict(self) -> dict[str, Any]: """To dictionary representation.""" @@ -97,6 +98,8 @@ def to_dict(self) -> dict[str, Any]: out["Propagation"] = self.propagation.value if self.read_only_non_recursive is not None: out["ReadOnlyNonRecursive"] = self.read_only_non_recursive + if self.create_mountpoint is not None: + out["CreateMountpoint"] = self.create_mountpoint return out @@ -140,6 +143,7 @@ def to_dict(self) -> dict[str, str | int]: } +ENV_CORE_API_SOCKET = "SUPERVISOR_CORE_API_SOCKET" ENV_DUPLICATE_LOG_FILE = "HA_DUPLICATE_LOG_FILE" ENV_TIME = "TZ" ENV_TOKEN = "SUPERVISOR_TOKEN" @@ -169,6 +173,12 @@ def to_dict(self) -> dict[str, str | int]: target=MACHINE_ID.as_posix(), read_only=True, ) +MOUNT_CORE_RUN = DockerMount( + type=MountType.BIND, + source="/run/supervisor", + target="/run/supervisor", + read_only=False, +) MOUNT_UDEV = DockerMount( type=MountType.BIND, source="/run/udev", target="/run/udev", read_only=True ) diff --git a/supervisor/docker/homeassistant.py b/supervisor/docker/homeassistant.py index c82af5a7b63..999877af1ea 100644 --- a/supervisor/docker/homeassistant.py +++ b/supervisor/docker/homeassistant.py @@ -13,10 +13,12 @@ from ..jobs.const import JobConcurrency from ..jobs.decorator import Job from .const import ( + ENV_CORE_API_SOCKET, ENV_DUPLICATE_LOG_FILE, ENV_TIME, ENV_TOKEN, ENV_TOKEN_OLD, + MOUNT_CORE_RUN, MOUNT_DBUS, MOUNT_DEV, MOUNT_MACHINE_ID, @@ -136,6 +138,8 @@ def mounts(self) -> list[DockerMount]: propagation=PropagationMode.RSLAVE ), ), + # Supervisor <-> Core communication socket + MOUNT_CORE_RUN, # Configuration audio DockerMount( type=MountType.BIND, @@ -180,6 +184,8 @@ async def run(self, *, restore_job_id: str | None = None) -> None: } if restore_job_id: environment[ENV_RESTORE_JOB_ID] = restore_job_id + if self.sys_homeassistant.api.use_unix_socket: + environment[ENV_CORE_API_SOCKET] = "/run/supervisor/core.sock" if self.sys_homeassistant.duplicate_log_file: environment[ENV_DUPLICATE_LOG_FILE] = "1" await self._run( diff --git a/supervisor/homeassistant/api.py b/supervisor/homeassistant/api.py index 3d3c5d7e51a..49b1ee8aa84 100644 --- a/supervisor/homeassistant/api.py +++ b/supervisor/homeassistant/api.py @@ -13,13 +13,18 @@ from awesomeversion import AwesomeVersion from multidict import MultiMapping +from ..const import SOCKET_CORE from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import HomeAssistantAPIError, HomeAssistantAuthError from ..utils import version_is_new_enough from .const import LANDINGPAGE +from .websocket import WSClient _LOGGER: logging.Logger = logging.getLogger(__name__) +CORE_UNIX_SOCKET_MIN_VERSION: AwesomeVersion = AwesomeVersion( + "2026.4.0.dev202603250907" +) GET_CORE_STATE_MIN_VERSION: AwesomeVersion = AwesomeVersion("2023.8.0.dev20230720") @@ -39,11 +44,67 @@ def __init__(self, coresys: CoreSys): self.coresys: CoreSys = coresys # We don't persist access tokens. Instead we fetch new ones when needed - self.access_token: str | None = None + self._access_token: str | None = None self._access_token_expires: datetime | None = None self._token_lock: asyncio.Lock = asyncio.Lock() - - async def ensure_access_token(self) -> None: + self._unix_session: aiohttp.ClientSession | None = None + self._logged_transport: bool = False + + @property + def use_unix_socket(self) -> bool: + """Return True if Core supports Unix socket communication.""" + return ( + self.sys_homeassistant.version is not None + and self.sys_homeassistant.version != LANDINGPAGE + and version_is_new_enough( + self.sys_homeassistant.version, CORE_UNIX_SOCKET_MIN_VERSION + ) + ) + + @property + def session(self) -> aiohttp.ClientSession: + """Return session for Core communication. + + Uses a Unix socket session when the installed Core version supports it, + otherwise falls back to the default TCP websession. + + Raises HomeAssistantAPIError if Unix socket is expected but missing. + """ + if not self.use_unix_socket: + return self.sys_websession + + if not SOCKET_CORE.exists(): + raise HomeAssistantAPIError( + f"Core Unix socket {SOCKET_CORE} does not exist", _LOGGER.error + ) + + if self._unix_session is None or self._unix_session.closed: + self._unix_session = aiohttp.ClientSession( + connector=aiohttp.UnixConnector(path=str(SOCKET_CORE)) + ) + return self._unix_session + + @property + def api_url(self) -> str: + """Return API base url for internal Supervisor to Core communication.""" + if self.use_unix_socket: + return "http://localhost" + return self.sys_homeassistant.api_url + + @property + def ws_url(self) -> str: + """Return WebSocket url for internal Supervisor to Core communication.""" + if self.use_unix_socket: + return "ws://localhost/api/websocket" + return self.sys_homeassistant.ws_url + + async def close(self) -> None: + """Close the Unix socket session.""" + if self._unix_session and not self._unix_session.closed: + await self._unix_session.close() + self._unix_session = None + + async def _ensure_access_token(self) -> None: """Ensure there is a valid access token. Raises: @@ -55,7 +116,7 @@ async def ensure_access_token(self) -> None: # Fast path check without lock (avoid unnecessary locking # for the majority of calls). if ( - self.access_token + self._access_token and self._access_token_expires and self._access_token_expires > datetime.now(tz=UTC) ): @@ -64,7 +125,7 @@ async def ensure_access_token(self) -> None: async with self._token_lock: # Double-check after acquiring lock (avoid race condition) if ( - self.access_token + self._access_token and self._access_token_expires and self._access_token_expires > datetime.now(tz=UTC) ): @@ -86,11 +147,47 @@ async def ensure_access_token(self) -> None: _LOGGER.info("Updated Home Assistant API token") tokens = await resp.json() - self.access_token = tokens["access_token"] + self._access_token = tokens["access_token"] self._access_token_expires = datetime.now(tz=UTC) + timedelta( seconds=tokens["expires_in"] ) + async def connect_websocket( + self, *, max_msg_size: int = 4 * 1024 * 1024 + ) -> WSClient: + """Connect a WebSocket to Core, handling auth as appropriate. + + For Unix socket connections, no authentication is needed. + For TCP connections, handles token management with one retry + on auth failure. + + Raises: + HomeAssistantAPIError: On connection or auth failure. + + """ + if self.use_unix_socket: + return await WSClient.connect( + self.session, self.ws_url, max_msg_size=max_msg_size + ) + + for attempt in (1, 2): + try: + await self._ensure_access_token() + assert self._access_token + return await WSClient.connect_with_auth( + self.session, + self.ws_url, + self._access_token, + max_msg_size=max_msg_size, + ) + except HomeAssistantAPIError: + if attempt == 2: + raise + self._access_token = None + + # Unreachable, but satisfies type checker + raise HomeAssistantAPIError("Failed to connect WebSocket") + @asynccontextmanager async def make_request( self, @@ -133,7 +230,7 @@ async def make_request( network errors, timeouts, or connection failures """ - url = f"{self.sys_homeassistant.api_url}/{path}" + url = f"{self.api_url}/{path}" headers = headers or {} client_timeout = aiohttp.ClientTimeout(total=timeout) @@ -141,11 +238,26 @@ async def make_request( if content_type is not None: headers[hdrs.CONTENT_TYPE] = content_type + use_unix = self.use_unix_socket + + if not self._logged_transport: + if use_unix: + _LOGGER.info( + "Setting up Core communication via Unix socket %s", SOCKET_CORE + ) + else: + _LOGGER.info( + "Setting up Core communication via TCP %s", + self.sys_homeassistant.api_url, + ) + self._logged_transport = True + for _ in (1, 2): try: - await self.ensure_access_token() - headers[hdrs.AUTHORIZATION] = f"Bearer {self.access_token}" - async with self.sys_websession.request( + if not use_unix: + await self._ensure_access_token() + headers[hdrs.AUTHORIZATION] = f"Bearer {self._access_token}" + async with self.session.request( method, url, data=data, @@ -155,9 +267,9 @@ async def make_request( params=params, ssl=False, ) as resp: - # Access token expired - if resp.status == 401: - self.access_token = None + # Access token expired (only relevant for TCP) + if resp.status == 401 and not use_unix: + self._access_token = None continue yield resp return diff --git a/supervisor/homeassistant/websocket.py b/supervisor/homeassistant/websocket.py index eab4d506116..6e35081d133 100644 --- a/supervisor/homeassistant/websocket.py +++ b/supervisor/homeassistant/websocket.py @@ -3,9 +3,8 @@ from __future__ import annotations import asyncio -from contextlib import suppress import logging -from typing import Any, TypeVar, cast +from typing import Any, TypeVar import aiohttp from aiohttp.http_websocket import WSMsgType @@ -45,14 +44,14 @@ def __init__( ): """Initialise the WS client.""" self.ha_version = ha_version - self._client = client + self.client = client self._message_id: int = 0 self._futures: dict[int, asyncio.Future[T]] = {} # type: ignore @property def connected(self) -> bool: """Return if we're currently connected.""" - return self._client is not None and not self._client.closed + return self.client is not None and not self.client.closed async def close(self) -> None: """Close down the client.""" @@ -62,8 +61,8 @@ async def close(self) -> None: HomeAssistantWSConnectionError("Connection was closed") ) - if not self._client.closed: - await self._client.close() + if not self.client.closed: + await self.client.close() async def async_send_command(self, message: dict[str, Any]) -> T: """Send a websocket message, and return the response.""" @@ -72,7 +71,7 @@ async def async_send_command(self, message: dict[str, Any]) -> T: self._futures[message["id"]] = asyncio.get_running_loop().create_future() _LOGGER.debug("Sending: %s", message) try: - await self._client.send_json(message, dumps=json_dumps) + await self.client.send_json(message, dumps=json_dumps) except ConnectionError as err: raise HomeAssistantWSConnectionError(str(err)) from err @@ -97,7 +96,7 @@ async def start_listener(self) -> None: async def _receive_json(self) -> None: """Receive json.""" - msg = await self._client.receive() + msg = await self.client.receive() _LOGGER.debug("Received: %s", msg) if msg.type == WSMsgType.CLOSE: @@ -139,16 +138,59 @@ async def _receive_json(self) -> None: ) @classmethod - async def connect_with_auth( - cls, session: aiohttp.ClientSession, url: str, token: str - ) -> WSClient: - """Create an authenticated websocket client.""" + async def _ws_connect( + cls, + session: aiohttp.ClientSession, + url: str, + *, + max_msg_size: int = 4 * 1024 * 1024, + ) -> aiohttp.ClientWebSocketResponse: + """Open a raw WebSocket connection to Core.""" try: - client = await session.ws_connect(url, ssl=False) + return await session.ws_connect(url, ssl=False, max_msg_size=max_msg_size) except aiohttp.client_exceptions.ClientConnectorError: raise HomeAssistantWSConnectionError("Can't connect") from None - hello_message = await client.receive_json() + @classmethod + async def connect( + cls, + session: aiohttp.ClientSession, + url: str, + *, + max_msg_size: int = 4 * 1024 * 1024, + ) -> WSClient: + """Connect via Unix socket (no auth exchange). + + Core authenticates the peer by the socket connection itself + and sends auth_ok immediately. + """ + client = await cls._ws_connect(session, url, max_msg_size=max_msg_size) + first_message = await client.receive_json() + + if first_message[ATTR_TYPE] != "auth_ok": + raise HomeAssistantAPIError( + f"Expected auth_ok on Unix socket, got {first_message[ATTR_TYPE]}" + ) + + return cls(AwesomeVersion(first_message["ha_version"]), client) + + @classmethod + async def connect_with_auth( + cls, + session: aiohttp.ClientSession, + url: str, + token: str, + *, + max_msg_size: int = 4 * 1024 * 1024, + ) -> WSClient: + """Connect via TCP with token authentication. + + Expects auth_required from Core, sends the token, then expects auth_ok. + The auth_required message also carries ha_version. + """ + client = await cls._ws_connect(session, url, max_msg_size=max_msg_size) + # auth_required message also carries ha_version + first_message = await client.receive_json() await client.send_json( {ATTR_TYPE: WSType.AUTH, ATTR_ACCESS_TOKEN: token}, dumps=json_dumps @@ -159,7 +201,7 @@ async def connect_with_auth( if auth_ok_message[ATTR_TYPE] != "auth_ok": raise HomeAssistantAPIError("AUTH NOT OK") - return cls(AwesomeVersion(hello_message["ha_version"]), client) + return cls(AwesomeVersion(first_message["ha_version"]), client) class HomeAssistantWebSocket(CoreSysAttributes): @@ -168,7 +210,7 @@ class HomeAssistantWebSocket(CoreSysAttributes): def __init__(self, coresys: CoreSys): """Initialize Home Assistant object.""" self.coresys: CoreSys = coresys - self._client: WSClient | None = None + self.client: WSClient | None = None self._lock: asyncio.Lock = asyncio.Lock() self._queue: list[dict[str, Any]] = [] @@ -183,16 +225,10 @@ async def _process_queue(self, reference: CoreState) -> None: async def _get_ws_client(self) -> WSClient: """Return a websocket client.""" async with self._lock: - if self._client is not None and self._client.connected: - return self._client - - with suppress(asyncio.TimeoutError, aiohttp.ClientError): - await self.sys_homeassistant.api.ensure_access_token() - client = await WSClient.connect_with_auth( - self.sys_websession, - self.sys_homeassistant.ws_url, - cast(str, self.sys_homeassistant.api.access_token), - ) + if self.client is not None and self.client.connected: + return self.client + + client = await self.sys_homeassistant.api.connect_websocket() self.sys_create_task(client.start_listener()) return client @@ -208,7 +244,7 @@ async def _ensure_connected(self) -> None: "WebSocket not available, system is shutting down" ) - connected = self._client and self._client.connected + connected = self.client and self.client.connected # If we are already connected, we can avoid the check_api_state call # since it makes a new socket connection and we already have one. if not connected and not await self.sys_homeassistant.api.check_api_state(): @@ -216,8 +252,8 @@ async def _ensure_connected(self) -> None: "Can't connect to Home Assistant Core WebSocket, the API is not reachable" ) - if not self._client or not self._client.connected: - self._client = await self._get_ws_client() + if not self.client or not self.client.connected: + self.client = await self._get_ws_client() async def load(self) -> None: """Set up queue processor after startup completes.""" @@ -241,16 +277,16 @@ async def _async_send_command(self, message: dict[str, Any]) -> None: _LOGGER.debug("Can't send WebSocket command: %s", err) return - # _ensure_connected guarantees self._client is set - assert self._client + # _ensure_connected guarantees self.client is set + assert self.client try: - await self._client.async_send_command(message) + await self.client.async_send_command(message) except HomeAssistantWSConnectionError as err: _LOGGER.debug("Fire-and-forget WebSocket command failed: %s", err) - if self._client: - await self._client.close() - self._client = None + if self.client: + await self.client.close() + self.client = None async def async_send_command(self, message: dict[str, Any]) -> T: """Send a command and return the response. @@ -258,14 +294,14 @@ async def async_send_command(self, message: dict[str, Any]) -> T: Raises HomeAssistantWSError on WebSocket connection or communication failure. """ await self._ensure_connected() - # _ensure_connected guarantees self._client is set - assert self._client + # _ensure_connected guarantees self.client is set + assert self.client try: - return await self._client.async_send_command(message) + return await self.client.async_send_command(message) except HomeAssistantWSConnectionError: - if self._client: - await self._client.close() - self._client = None + if self.client: + await self.client.close() + self.client = None raise def send_command(self, message: dict[str, Any]) -> None: diff --git a/tests/addons/test_manager.py b/tests/addons/test_manager.py index 04b8d2a9cf4..1e1447e263b 100644 --- a/tests/addons/test_manager.py +++ b/tests/addons/test_manager.py @@ -246,7 +246,7 @@ async def test_addon_uninstall_removes_discovery( assert message.service == "mqtt" assert coresys.discovery.list_messages == [message] - coresys.homeassistant.api.ensure_access_token = AsyncMock() + coresys.homeassistant.api._ensure_access_token = AsyncMock() await coresys.addons.uninstall(TEST_ADDON_SLUG) await asyncio.sleep(0) diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py index 132dbab548f..4dddd53068c 100644 --- a/tests/api/test_auth.py +++ b/tests/api/test_auth.py @@ -88,7 +88,7 @@ async def test_password_reset( websession: MagicMock, ): """Test password reset api.""" - coresys.homeassistant.api.access_token = "abc123" + coresys.homeassistant.api._access_token = "abc123" # pylint: disable-next=protected-access coresys.homeassistant.api._access_token_expires = datetime.now(tz=UTC) + timedelta( days=1 @@ -124,7 +124,7 @@ async def test_failed_password_reset( expected_log: str, ): """Test failed password reset.""" - coresys.homeassistant.api.access_token = "abc123" + coresys.homeassistant.api._access_token = "abc123" # pylint: disable-next=protected-access coresys.homeassistant.api._access_token_expires = datetime.now(tz=UTC) + timedelta( days=1 diff --git a/tests/api/test_discovery.py b/tests/api/test_discovery.py index fc2d4850c02..012f4c7d5c8 100644 --- a/tests/api/test_discovery.py +++ b/tests/api/test_discovery.py @@ -91,7 +91,7 @@ async def test_api_send_del_discovery( ): """Test adding and removing discovery.""" install_addon_ssh.data["discovery"] = ["test"] - coresys.homeassistant.api.ensure_access_token = AsyncMock() + coresys.homeassistant.api._ensure_access_token = AsyncMock() resp = await api_client.post("/discovery", json={"service": "test", "config": {}}) assert resp.status == 200 diff --git a/tests/conftest.py b/tests/conftest.py index 0774105ec06..1e002e1dc47 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -560,7 +560,7 @@ async def coresys( coresys_obj.homeassistant.api.get_api_state = AsyncMock( return_value=APIState("RUNNING", False) ) - coresys_obj.homeassistant._websocket._client = AsyncMock( + coresys_obj.homeassistant._websocket.client = AsyncMock( ha_version=AwesomeVersion("2021.2.4") ) @@ -580,7 +580,7 @@ async def ha_ws_client(coresys: CoreSys) -> AsyncMock: # Set Supervisor Core state to RUNNING, otherwise WS events won't be delivered await coresys.core.set_state(CoreState.RUNNING) await asyncio.sleep(0) - client = coresys.homeassistant.websocket._client + client = coresys.homeassistant.websocket.client client.async_send_command.reset_mock() return client diff --git a/tests/docker/test_homeassistant.py b/tests/docker/test_homeassistant.py index c8a3e64d7b4..8f3bbbc30e7 100644 --- a/tests/docker/test_homeassistant.py +++ b/tests/docker/test_homeassistant.py @@ -9,6 +9,7 @@ from supervisor.coresys import CoreSys from supervisor.docker.const import ( + MOUNT_CORE_RUN, DockerMount, MountBindOptions, MountType, @@ -93,6 +94,7 @@ async def test_homeassistant_start(coresys: CoreSys, container: DockerContainer) read_only=False, bind_options=MountBindOptions(propagation=PropagationMode.RSLAVE), ), + MOUNT_CORE_RUN, DockerMount( type=MountType.BIND, source=coresys.homeassistant.path_extern_pulse.as_posix(), @@ -144,6 +146,28 @@ async def test_homeassistant_start_with_duplicate_log_file( assert env["HA_DUPLICATE_LOG_FILE"] == "1" +@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern") +async def test_homeassistant_start_with_unix_socket( + coresys: CoreSys, container: DockerContainer +): + """Test starting homeassistant with unix socket env var for supported version.""" + coresys.homeassistant.version = AwesomeVersion("2026.4.0") + + with ( + patch.object(DockerAPI, "run", return_value=container.show.return_value) as run, + patch.object( + DockerHomeAssistant, "is_running", side_effect=[False, False, True] + ), + patch("supervisor.homeassistant.core.asyncio.sleep"), + ): + await coresys.homeassistant.core.start() + + run.assert_called_once() + env = run.call_args.kwargs["environment"] + assert "SUPERVISOR_CORE_API_SOCKET" in env + assert env["SUPERVISOR_CORE_API_SOCKET"] == "/run/supervisor/core.sock" + + @pytest.mark.usefixtures("tmp_supervisor_data", "path_extern") async def test_landingpage_start(coresys: CoreSys, container: DockerContainer): """Test starting landingpage.""" diff --git a/tests/homeassistant/test_module.py b/tests/homeassistant/test_module.py index dec68cb8704..a2e636f4b8e 100644 --- a/tests/homeassistant/test_module.py +++ b/tests/homeassistant/test_module.py @@ -87,8 +87,7 @@ async def test_write_pulse_error(coresys: CoreSys, caplog: pytest.LogCaptureFixt async def test_begin_backup_ws_error(coresys: CoreSys): """Test WS error when beginning backup.""" - # pylint: disable-next=protected-access - coresys.homeassistant.websocket._client.async_send_command.side_effect = ( + coresys.homeassistant.websocket.client.async_send_command.side_effect = ( HomeAssistantWSConnectionError("Connection was closed") ) with ( @@ -103,8 +102,7 @@ async def test_begin_backup_ws_error(coresys: CoreSys): async def test_end_backup_ws_error(coresys: CoreSys, caplog: pytest.LogCaptureFixture): """Test WS error when ending backup.""" - # pylint: disable-next=protected-access - coresys.homeassistant.websocket._client.async_send_command.side_effect = ( + coresys.homeassistant.websocket.client.async_send_command.side_effect = ( HomeAssistantWSConnectionError("Connection was closed") ) with patch.object(HomeAssistantWebSocket, "_ensure_connected", return_value=None): From 57935018f185f19e51ecfca4d7b39d6c7f822315 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 1 Apr 2026 19:30:44 +0200 Subject: [PATCH 02/16] Close WebSocket on handshake failure and validate auth_required Ensure the underlying WebSocket connection is closed before raising when the handshake produces an unexpected message. Also validate that the first TCP message is auth_required before sending credentials. Co-Authored-By: Claude Opus 4.6 (1M context) --- supervisor/homeassistant/websocket.py | 43 +++++++++++++++++---------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/supervisor/homeassistant/websocket.py b/supervisor/homeassistant/websocket.py index 6e35081d133..ebe37f564f6 100644 --- a/supervisor/homeassistant/websocket.py +++ b/supervisor/homeassistant/websocket.py @@ -165,14 +165,18 @@ async def connect( and sends auth_ok immediately. """ client = await cls._ws_connect(session, url, max_msg_size=max_msg_size) - first_message = await client.receive_json() + try: + first_message = await client.receive_json() - if first_message[ATTR_TYPE] != "auth_ok": - raise HomeAssistantAPIError( - f"Expected auth_ok on Unix socket, got {first_message[ATTR_TYPE]}" - ) + if first_message[ATTR_TYPE] != "auth_ok": + raise HomeAssistantAPIError( + f"Expected auth_ok on Unix socket, got {first_message[ATTR_TYPE]}" + ) - return cls(AwesomeVersion(first_message["ha_version"]), client) + return cls(AwesomeVersion(first_message["ha_version"]), client) + except Exception: + await client.close() + raise @classmethod async def connect_with_auth( @@ -189,19 +193,28 @@ async def connect_with_auth( The auth_required message also carries ha_version. """ client = await cls._ws_connect(session, url, max_msg_size=max_msg_size) - # auth_required message also carries ha_version - first_message = await client.receive_json() + try: + # auth_required message also carries ha_version + first_message = await client.receive_json() - await client.send_json( - {ATTR_TYPE: WSType.AUTH, ATTR_ACCESS_TOKEN: token}, dumps=json_dumps - ) + if first_message[ATTR_TYPE] != "auth_required": + raise HomeAssistantAPIError( + f"Expected auth_required, got {first_message[ATTR_TYPE]}" + ) - auth_ok_message = await client.receive_json() + await client.send_json( + {ATTR_TYPE: WSType.AUTH, ATTR_ACCESS_TOKEN: token}, dumps=json_dumps + ) - if auth_ok_message[ATTR_TYPE] != "auth_ok": - raise HomeAssistantAPIError("AUTH NOT OK") + auth_ok_message = await client.receive_json() - return cls(AwesomeVersion(first_message["ha_version"]), client) + if auth_ok_message[ATTR_TYPE] != "auth_ok": + raise HomeAssistantAPIError("AUTH NOT OK") + + return cls(AwesomeVersion(first_message["ha_version"]), client) + except Exception: + await client.close() + raise class HomeAssistantWebSocket(CoreSysAttributes): From 2ad550a0b5f8f359d0377d2cb18a754623406989 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 1 Apr 2026 19:32:03 +0200 Subject: [PATCH 03/16] Fix pylint protected-access warnings in tests Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/addons/test_manager.py | 2 +- tests/api/test_auth.py | 4 ++-- tests/api/test_discovery.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/addons/test_manager.py b/tests/addons/test_manager.py index 1e1447e263b..ba0fb7289ee 100644 --- a/tests/addons/test_manager.py +++ b/tests/addons/test_manager.py @@ -246,7 +246,7 @@ async def test_addon_uninstall_removes_discovery( assert message.service == "mqtt" assert coresys.discovery.list_messages == [message] - coresys.homeassistant.api._ensure_access_token = AsyncMock() + coresys.homeassistant.api._ensure_access_token = AsyncMock() # pylint: disable=protected-access await coresys.addons.uninstall(TEST_ADDON_SLUG) await asyncio.sleep(0) diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py index 4dddd53068c..bcdac6f97c3 100644 --- a/tests/api/test_auth.py +++ b/tests/api/test_auth.py @@ -88,7 +88,7 @@ async def test_password_reset( websession: MagicMock, ): """Test password reset api.""" - coresys.homeassistant.api._access_token = "abc123" + coresys.homeassistant.api._access_token = "abc123" # pylint: disable=protected-access # pylint: disable-next=protected-access coresys.homeassistant.api._access_token_expires = datetime.now(tz=UTC) + timedelta( days=1 @@ -124,7 +124,7 @@ async def test_failed_password_reset( expected_log: str, ): """Test failed password reset.""" - coresys.homeassistant.api._access_token = "abc123" + coresys.homeassistant.api._access_token = "abc123" # pylint: disable=protected-access # pylint: disable-next=protected-access coresys.homeassistant.api._access_token_expires = datetime.now(tz=UTC) + timedelta( days=1 diff --git a/tests/api/test_discovery.py b/tests/api/test_discovery.py index 012f4c7d5c8..5da57fcfc3f 100644 --- a/tests/api/test_discovery.py +++ b/tests/api/test_discovery.py @@ -91,7 +91,7 @@ async def test_api_send_del_discovery( ): """Test adding and removing discovery.""" install_addon_ssh.data["discovery"] = ["test"] - coresys.homeassistant.api._ensure_access_token = AsyncMock() + coresys.homeassistant.api._ensure_access_token = AsyncMock() # pylint: disable=protected-access resp = await api_client.post("/discovery", json={"service": "test", "config": {}}) assert resp.status == 200 From ef4f88215b322dff653ac073feea22298348ccc1 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 1 Apr 2026 19:48:23 +0200 Subject: [PATCH 04/16] Check running container env before using Unix socket Split use_unix_socket into two properties to handle the Supervisor upgrade transition where Core is still running with a container started by the old Supervisor (without SUPERVISOR_CORE_API_SOCKET): - supports_unix_socket: version check only, used when creating the Core container to decide whether to set the env var - use_unix_socket: version check + running container env check, used for communication decisions This ensures TCP fallback during the upgrade transition while still hard-failing if the socket is missing after Supervisor configured Core to use it. Co-Authored-By: Claude Opus 4.6 (1M context) --- supervisor/docker/homeassistant.py | 2 +- supervisor/homeassistant/api.py | 26 ++++++++++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/supervisor/docker/homeassistant.py b/supervisor/docker/homeassistant.py index 999877af1ea..c71f6d1d4a6 100644 --- a/supervisor/docker/homeassistant.py +++ b/supervisor/docker/homeassistant.py @@ -184,7 +184,7 @@ async def run(self, *, restore_job_id: str | None = None) -> None: } if restore_job_id: environment[ENV_RESTORE_JOB_ID] = restore_job_id - if self.sys_homeassistant.api.use_unix_socket: + if self.sys_homeassistant.api.supports_unix_socket: environment[ENV_CORE_API_SOCKET] = "/run/supervisor/core.sock" if self.sys_homeassistant.duplicate_log_file: environment[ENV_DUPLICATE_LOG_FILE] = "1" diff --git a/supervisor/homeassistant/api.py b/supervisor/homeassistant/api.py index 49b1ee8aa84..e1ccb323199 100644 --- a/supervisor/homeassistant/api.py +++ b/supervisor/homeassistant/api.py @@ -15,6 +15,7 @@ from ..const import SOCKET_CORE from ..coresys import CoreSys, CoreSysAttributes +from ..docker.const import ENV_CORE_API_SOCKET from ..exceptions import HomeAssistantAPIError, HomeAssistantAuthError from ..utils import version_is_new_enough from .const import LANDINGPAGE @@ -51,8 +52,11 @@ def __init__(self, coresys: CoreSys): self._logged_transport: bool = False @property - def use_unix_socket(self) -> bool: - """Return True if Core supports Unix socket communication.""" + def supports_unix_socket(self) -> bool: + """Return True if the installed Core version supports Unix socket communication. + + Used to decide whether to configure the env var when starting Core. + """ return ( self.sys_homeassistant.version is not None and self.sys_homeassistant.version != LANDINGPAGE @@ -61,6 +65,24 @@ def use_unix_socket(self) -> bool: ) ) + @property + def use_unix_socket(self) -> bool: + """Return True if the running Core container is configured for Unix socket. + + Checks both version support and that the container was actually started + with the SUPERVISOR_CORE_API_SOCKET env var. This prevents failures + during Supervisor upgrades where Core is still running with a container + started by the old Supervisor. + """ + if not self.supports_unix_socket: + return False + meta_config = self.sys_homeassistant.core.instance.meta_config + if not meta_config or "Env" not in meta_config: + return False + return any( + env.startswith(f"{ENV_CORE_API_SOCKET}=") for env in meta_config["Env"] + ) + @property def session(self) -> aiohttp.ClientSession: """Return session for Core communication. From 916c63d9bb4b7590f8bb493d674edfd26cd9f60a Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 8 Apr 2026 17:21:17 +0200 Subject: [PATCH 05/16] Improve Core API communication logging and error handling - Remove transport log from make_request that logged before Core container was attached, causing misleading connection logs - Log "Connected to Core via ..." once on first successful API response in get_api_state, when the transport is actually known - Remove explicit socket existence check from session property, let aiohttp UnixConnector produce natural connection errors during Core startup (same as TCP connection refused) - Add validation in get_core_state matching get_config pattern - Restore make_request docstring Co-Authored-By: Claude Opus 4.6 (1M context) --- supervisor/homeassistant/api.py | 72 +++++++++++++++------------------ 1 file changed, 32 insertions(+), 40 deletions(-) diff --git a/supervisor/homeassistant/api.py b/supervisor/homeassistant/api.py index e1ccb323199..c9a3c210e6f 100644 --- a/supervisor/homeassistant/api.py +++ b/supervisor/homeassistant/api.py @@ -49,7 +49,7 @@ def __init__(self, coresys: CoreSys): self._access_token_expires: datetime | None = None self._token_lock: asyncio.Lock = asyncio.Lock() self._unix_session: aiohttp.ClientSession | None = None - self._logged_transport: bool = False + self._core_connected: bool = False @property def supports_unix_socket(self) -> bool: @@ -88,18 +88,13 @@ def session(self) -> aiohttp.ClientSession: """Return session for Core communication. Uses a Unix socket session when the installed Core version supports it, - otherwise falls back to the default TCP websession. - - Raises HomeAssistantAPIError if Unix socket is expected but missing. + otherwise falls back to the default TCP websession. If the socket does + not exist yet (e.g. during Core startup), requests will fail with a + connection error handled by the caller. """ if not self.use_unix_socket: return self.sys_websession - if not SOCKET_CORE.exists(): - raise HomeAssistantAPIError( - f"Core Unix socket {SOCKET_CORE} does not exist", _LOGGER.error - ) - if self._unix_session is None or self._unix_session.closed: self._unix_session = aiohttp.ClientSession( connector=aiohttp.UnixConnector(path=str(SOCKET_CORE)) @@ -222,15 +217,16 @@ async def make_request( params: MultiMapping[str] | None = None, headers: dict[str, str] | None = None, ) -> AsyncIterator[aiohttp.ClientResponse]: - """Async context manager to make authenticated requests to Home Assistant API. + """Async context manager to make requests to Home Assistant Core API. - This context manager handles authentication token management automatically, - including token refresh on 401 responses. It yields the HTTP response - for the caller to handle. + This context manager handles transport and authentication automatically. + For Unix socket connections, requests are made directly without auth. + For TCP connections, it manages access tokens and retries once on 401. + It yields the HTTP response for the caller to handle. Error Handling: - HTTP error status codes (4xx, 5xx) are preserved in the response - - Authentication is handled transparently with one retry on 401 + - Authentication is handled transparently (TCP only) - Network/connection failures raise HomeAssistantAPIError - No logging is performed - callers should handle logging as needed @@ -256,27 +252,12 @@ async def make_request( headers = headers or {} client_timeout = aiohttp.ClientTimeout(total=timeout) - # Passthrough content type if content_type is not None: headers[hdrs.CONTENT_TYPE] = content_type - use_unix = self.use_unix_socket - - if not self._logged_transport: - if use_unix: - _LOGGER.info( - "Setting up Core communication via Unix socket %s", SOCKET_CORE - ) - else: - _LOGGER.info( - "Setting up Core communication via TCP %s", - self.sys_homeassistant.api_url, - ) - self._logged_transport = True - for _ in (1, 2): try: - if not use_unix: + if not self.use_unix_socket: await self._ensure_access_token() headers[hdrs.AUTHORIZATION] = f"Bearer {self._access_token}" async with self.session.request( @@ -289,8 +270,7 @@ async def make_request( params=params, ssl=False, ) as resp: - # Access token expired (only relevant for TCP) - if resp.status == 401 and not use_unix: + if resp.status == 401 and not self.use_unix_socket: self._access_token = None continue yield resp @@ -318,7 +298,10 @@ async def get_config(self) -> dict[str, Any]: async def get_core_state(self) -> dict[str, Any]: """Return Home Assistant core state.""" - return await self._get_json("api/core/state") + state = await self._get_json("api/core/state") + if state is None or not isinstance(state, dict): + raise HomeAssistantAPIError("No state received from Home Assistant API") + return state async def get_api_state(self) -> APIState | None: """Return state of Home Assistant Core or None.""" @@ -340,14 +323,23 @@ async def get_api_state(self) -> APIState | None: data = await self.get_core_state() else: data = await self.get_config() + + if not self._core_connected: + self._core_connected = True + transport = ( + f"Unix socket {SOCKET_CORE}" + if self.use_unix_socket + else f"TCP {self.sys_homeassistant.api_url}" + ) + _LOGGER.info("Connected to Core via %s", transport) + # Older versions of home assistant does not expose the state - if data: - state = data.get("state", "RUNNING") - # Recorder state was added in HA Core 2024.8 - recorder_state = data.get("recorder_state", {}) - migrating = recorder_state.get("migration_in_progress", False) - live_migration = recorder_state.get("migration_is_live", False) - return APIState(state, migrating and not live_migration) + state = data.get("state", "RUNNING") + # Recorder state was added in HA Core 2024.8 + recorder_state = data.get("recorder_state", {}) + migrating = recorder_state.get("migration_in_progress", False) + live_migration = recorder_state.get("migration_is_live", False) + return APIState(state, migrating and not live_migration) except HomeAssistantAPIError as err: _LOGGER.debug("Can't connect to Home Assistant API: %s", err) From f06f8bda62739aac1eb87c2a46a5adc1ac33196a Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 8 Apr 2026 18:23:40 +0200 Subject: [PATCH 06/16] Guard Core API requests with container running check Add is_running() check to make_request and connect_websocket so no HTTP or WebSocket connection is attempted when the Core container is not running. This avoids misleading connection attempts during Supervisor startup before Core is ready. Also make use_unix_socket raise if container metadata is not available instead of silently falling back to TCP. This is a defensive check since is_running() guards should prevent reaching this state. Add attached property to DockerInterface to expose whether container metadata has been loaded. Co-Authored-By: Claude Opus 4.6 (1M context) --- supervisor/docker/interface.py | 5 +++++ supervisor/homeassistant/api.py | 20 ++++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/supervisor/docker/interface.py b/supervisor/docker/interface.py index ef590bfc9ca..6b8c9221773 100644 --- a/supervisor/docker/interface.py +++ b/supervisor/docker/interface.py @@ -115,6 +115,11 @@ def timeout(self) -> int: def name(self) -> str: """Return name of Docker container.""" + @property + def attached(self) -> bool: + """Return True if container/image metadata has been loaded.""" + return self._meta is not None + @property def meta_config(self) -> dict[str, Any]: """Return meta data of configuration for container/image.""" diff --git a/supervisor/homeassistant/api.py b/supervisor/homeassistant/api.py index c9a3c210e6f..4d4b6778d17 100644 --- a/supervisor/homeassistant/api.py +++ b/supervisor/homeassistant/api.py @@ -73,14 +73,20 @@ def use_unix_socket(self) -> bool: with the SUPERVISOR_CORE_API_SOCKET env var. This prevents failures during Supervisor upgrades where Core is still running with a container started by the old Supervisor. + + Requires container metadata to be available (via attach() or run()). + Callers should ensure the container is running before using this. """ if not self.supports_unix_socket: return False - meta_config = self.sys_homeassistant.core.instance.meta_config - if not meta_config or "Env" not in meta_config: - return False + instance = self.sys_homeassistant.core.instance + if not instance.attached: + raise HomeAssistantAPIError( + "Cannot determine Core connection mode: container metadata not available" + ) return any( - env.startswith(f"{ENV_CORE_API_SOCKET}=") for env in meta_config["Env"] + env.startswith(f"{ENV_CORE_API_SOCKET}=") + for env in instance.meta_config.get("Env", []) ) @property @@ -182,6 +188,9 @@ async def connect_websocket( HomeAssistantAPIError: On connection or auth failure. """ + if not await self.sys_homeassistant.core.instance.is_running(): + raise HomeAssistantAPIError("Core container is not running", _LOGGER.debug) + if self.use_unix_socket: return await WSClient.connect( self.session, self.ws_url, max_msg_size=max_msg_size @@ -248,6 +257,9 @@ async def make_request( network errors, timeouts, or connection failures """ + if not await self.sys_homeassistant.core.instance.is_running(): + raise HomeAssistantAPIError("Core container is not running", _LOGGER.debug) + url = f"{self.api_url}/{path}" headers = headers or {} client_timeout = aiohttp.ClientTimeout(total=timeout) From b6c9704e438656874eebe49a8b9e0549a3c3d953 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 8 Apr 2026 18:41:26 +0200 Subject: [PATCH 07/16] Reset Core API connection state on container stop Listen for Core container STOPPED/FAILED events to reset the connection state: clear the _core_connected flag so the transport is logged again on next successful connection, and close any stale Unix socket session. Co-Authored-By: Claude Opus 4.6 (1M context) --- supervisor/homeassistant/api.py | 15 ++++++++++++++- supervisor/homeassistant/module.py | 4 ++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/supervisor/homeassistant/api.py b/supervisor/homeassistant/api.py index 4d4b6778d17..f72863f7ff4 100644 --- a/supervisor/homeassistant/api.py +++ b/supervisor/homeassistant/api.py @@ -15,7 +15,8 @@ from ..const import SOCKET_CORE from ..coresys import CoreSys, CoreSysAttributes -from ..docker.const import ENV_CORE_API_SOCKET +from ..docker.const import ENV_CORE_API_SOCKET, ContainerState +from ..docker.monitor import DockerContainerStateEvent from ..exceptions import HomeAssistantAPIError, HomeAssistantAuthError from ..utils import version_is_new_enough from .const import LANDINGPAGE @@ -121,6 +122,18 @@ def ws_url(self) -> str: return "ws://localhost/api/websocket" return self.sys_homeassistant.ws_url + async def container_state_changed(self, event: DockerContainerStateEvent) -> None: + """Process Core container state changes.""" + if event.name != self.sys_homeassistant.core.instance.name: + return + if event.state not in (ContainerState.STOPPED, ContainerState.FAILED): + return + + self._core_connected = False + if self._unix_session and not self._unix_session.closed: + await self._unix_session.close() + self._unix_session = None + async def close(self) -> None: """Close the Unix socket session.""" if self._unix_session and not self._unix_session.closed: diff --git a/supervisor/homeassistant/module.py b/supervisor/homeassistant/module.py index 7ff52c406a2..8df2b71c8fe 100644 --- a/supervisor/homeassistant/module.py +++ b/supervisor/homeassistant/module.py @@ -318,6 +318,10 @@ async def load(self) -> None: ) # Register for events + self.sys_bus.register_event( + BusEvent.DOCKER_CONTAINER_STATE_CHANGE, + self._api.container_state_changed, + ) self.sys_bus.register_event(BusEvent.HARDWARE_NEW_DEVICE, self._hardware_events) self.sys_bus.register_event( BusEvent.HARDWARE_REMOVE_DEVICE, self._hardware_events From c6d2a58bac8d6f78022e8bf24f56ebc4ee9b6e1c Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 8 Apr 2026 18:54:26 +0200 Subject: [PATCH 08/16] Only mount /run/supervisor if we use it --- supervisor/docker/homeassistant.py | 5 +++-- tests/docker/test_homeassistant.py | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/supervisor/docker/homeassistant.py b/supervisor/docker/homeassistant.py index c71f6d1d4a6..734fa4848a6 100644 --- a/supervisor/docker/homeassistant.py +++ b/supervisor/docker/homeassistant.py @@ -138,8 +138,6 @@ def mounts(self) -> list[DockerMount]: propagation=PropagationMode.RSLAVE ), ), - # Supervisor <-> Core communication socket - MOUNT_CORE_RUN, # Configuration audio DockerMount( type=MountType.BIND, @@ -166,6 +164,9 @@ def mounts(self) -> list[DockerMount]: if self.sys_machine_id: mounts.append(MOUNT_MACHINE_ID) + if self.sys_homeassistant.api.supports_unix_socket: + mounts.append(MOUNT_CORE_RUN) + return mounts @Job( diff --git a/tests/docker/test_homeassistant.py b/tests/docker/test_homeassistant.py index 8f3bbbc30e7..423b719a760 100644 --- a/tests/docker/test_homeassistant.py +++ b/tests/docker/test_homeassistant.py @@ -25,7 +25,7 @@ @pytest.mark.usefixtures("tmp_supervisor_data", "path_extern") async def test_homeassistant_start(coresys: CoreSys, container: DockerContainer): """Test starting homeassistant.""" - coresys.homeassistant.version = AwesomeVersion("2023.8.1") + coresys.homeassistant.version = AwesomeVersion("2026.4.0") with ( patch.object(DockerAPI, "run", return_value=container.show.return_value) as run, @@ -52,7 +52,7 @@ async def test_homeassistant_start(coresys: CoreSys, container: DockerContainer) "TZ": ANY, "SUPERVISOR_TOKEN": ANY, "HASSIO_TOKEN": ANY, - # no "HA_DUPLICATE_LOG_FILE" + "SUPERVISOR_CORE_API_SOCKET": "/run/supervisor/core.sock", } assert run.call_args.kwargs["mounts"] == [ DEV_MOUNT, @@ -94,7 +94,6 @@ async def test_homeassistant_start(coresys: CoreSys, container: DockerContainer) read_only=False, bind_options=MountBindOptions(propagation=PropagationMode.RSLAVE), ), - MOUNT_CORE_RUN, DockerMount( type=MountType.BIND, source=coresys.homeassistant.path_extern_pulse.as_posix(), @@ -119,6 +118,7 @@ async def test_homeassistant_start(coresys: CoreSys, container: DockerContainer) target="/etc/machine-id", read_only=True, ), + MOUNT_CORE_RUN, ] assert "volumes" not in run.call_args.kwargs From 6d32b01fe1bfde835cf0710962c9632720e520fa Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 8 Apr 2026 19:39:44 +0200 Subject: [PATCH 09/16] Fix pytest errors --- tests/conftest.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 1e002e1dc47..8be38ef02e9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -556,7 +556,7 @@ async def coresys( Path(__file__).parent.joinpath("fixtures"), "apparmor" ) - # WebSocket + # Home Assistant Core API coresys_obj.homeassistant.api.get_api_state = AsyncMock( return_value=APIState("RUNNING", False) ) @@ -707,8 +707,13 @@ def supervisor_internet(coresys: CoreSys) -> Generator[AsyncMock]: @pytest.fixture def websession(coresys: CoreSys) -> Generator[MagicMock]: - """Fixture for global aiohttp SessionClient.""" + """Fixture for global aiohttp SessionClient. + + Also mocks Core container is_running to return True so that + make_request doesn't bail before reaching the websession. + """ coresys._websession = MagicMock(spec_set=ClientSession) + coresys.homeassistant.core.instance.is_running = AsyncMock(return_value=True) yield coresys._websession From 94adca178f207930ea488ab1e64c716639ad8176 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 8 Apr 2026 20:51:44 +0200 Subject: [PATCH 10/16] Remove redundant is_running check from ingress panel update The is_running() guard in update_hass_panel is now redundant since make_request checks is_running() internally. Also mock is_running in the websession test fixture since tests using it need make_request to proceed past the container running check. Co-Authored-By: Claude Opus 4.6 (1M context) --- supervisor/ingress.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/supervisor/ingress.py b/supervisor/ingress.py index 859d8586613..04428824447 100644 --- a/supervisor/ingress.py +++ b/supervisor/ingress.py @@ -185,12 +185,7 @@ async def del_dynamic_port(self, addon_slug: str) -> None: await self.save_data() async def update_hass_panel(self, addon: Addon): - """Return True if Home Assistant up and running.""" - if not await self.sys_homeassistant.core.is_running(): - _LOGGER.debug("Ignoring panel update on Core") - return - - # Update UI + """Update the ingress panel registration in Home Assistant.""" method = "post" if addon.ingress_panel else "delete" try: async with self.sys_homeassistant.api.make_request( From b3a9efff526c3611353923beeec8fd5b1ceb3198 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 8 Apr 2026 22:58:20 +0200 Subject: [PATCH 11/16] Bind mount /run/supervisor to Supervisor /run/os Home Assistant OS (as well as the Supervised run scripts) bind mount /run/supervisor to /run/os in Supervisor. Since we reuse this location for the communication socket between Supervisor and Core, we need to also bind mount /run/supervisor to Supervisor /run/os in CI. --- .github/workflows/builder.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 8c8c9978aa2..624ca0d89f2 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -284,9 +284,10 @@ jobs: --privileged \ --security-opt seccomp=unconfined \ --security-opt apparmor=unconfined \ - -v /run/docker.sock:/run/docker.sock \ - -v /run/dbus:/run/dbus \ - -v /tmp/supervisor/data:/data \ + -v /run/docker.sock:/run/docker.sock:rw \ + -v /run/dbus:/run/dbus:ro \ + -v /run/supervisor:/run/os:rw \ + -v /tmp/supervisor/data:/data:rw,slave \ -v /etc/machine-id:/etc/machine-id:ro \ -e SUPERVISOR_SHARE="/tmp/supervisor/data" \ -e SUPERVISOR_NAME=hassio_supervisor \ From 20e72b7132838378359bf9da040a2195723aadaf Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 9 Apr 2026 13:39:54 +0200 Subject: [PATCH 12/16] Wrap WebSocket handshake errors in HomeAssistantAPIError Unexpected exceptions during the WebSocket handshake (KeyError, ValueError, TypeError from malformed messages) are now wrapped in HomeAssistantAPIError inside WSClient.connect/connect_with_auth. This means callers only need to catch HomeAssistantAPIError. Remove the now-unnecessary except (RuntimeError, ValueError, TypeError) from proxy _websocket_client and add a proper error message to the APIError per review feedback. Co-Authored-By: Claude Opus 4.6 (1M context) --- supervisor/api/proxy.py | 9 ++++----- supervisor/homeassistant/websocket.py | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/supervisor/api/proxy.py b/supervisor/api/proxy.py index 5b5330f4734..a505b0196f2 100644 --- a/supervisor/api/proxy.py +++ b/supervisor/api/proxy.py @@ -184,11 +184,10 @@ async def _websocket_client(self) -> ClientWebSocketResponse: ) return ws_client.client except HomeAssistantAPIError as err: - _LOGGER.error("Error connecting to Home Assistant WebSocket: %s", err) - raise APIError() from err - except (RuntimeError, ValueError, TypeError) as err: - _LOGGER.error("Client error on WebSocket API %s.", err) - raise APIError() from err + raise APIError( + f"Error connecting to Home Assistant WebSocket: {err}", + _LOGGER.error, + ) from err async def _proxy_message( self, diff --git a/supervisor/homeassistant/websocket.py b/supervisor/homeassistant/websocket.py index ebe37f564f6..a0a37dc085f 100644 --- a/supervisor/homeassistant/websocket.py +++ b/supervisor/homeassistant/websocket.py @@ -174,9 +174,14 @@ async def connect( ) return cls(AwesomeVersion(first_message["ha_version"]), client) - except Exception: + except HomeAssistantAPIError: await client.close() raise + except Exception as err: + await client.close() + raise HomeAssistantAPIError( + f"Unexpected error during WebSocket handshake: {err}" + ) from err @classmethod async def connect_with_auth( @@ -212,9 +217,14 @@ async def connect_with_auth( raise HomeAssistantAPIError("AUTH NOT OK") return cls(AwesomeVersion(first_message["ha_version"]), client) - except Exception: + except HomeAssistantAPIError: await client.close() raise + except Exception as err: + await client.close() + raise HomeAssistantAPIError( + f"Unexpected error during WebSocket handshake: {err}" + ) from err class HomeAssistantWebSocket(CoreSysAttributes): From d022710df534e94d6c6aace4f3d23bd41a9250a2 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 9 Apr 2026 13:51:10 +0200 Subject: [PATCH 13/16] Narrow WebSocket handshake exception handling Replace broad `except Exception` with specific exception types that can actually occur during the WebSocket handshake: KeyError (missing dict keys), ValueError (bad JSON), TypeError (non-text WS message), aiohttp.ClientError (connection errors), and TimeoutError. This avoids silently wrapping programming errors into HomeAssistantAPIError. Co-Authored-By: Claude Opus 4.6 (1M context) --- supervisor/homeassistant/websocket.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/supervisor/homeassistant/websocket.py b/supervisor/homeassistant/websocket.py index a0a37dc085f..79cc781143c 100644 --- a/supervisor/homeassistant/websocket.py +++ b/supervisor/homeassistant/websocket.py @@ -177,7 +177,13 @@ async def connect( except HomeAssistantAPIError: await client.close() raise - except Exception as err: + except ( + KeyError, + ValueError, + TypeError, + aiohttp.ClientError, + TimeoutError, + ) as err: await client.close() raise HomeAssistantAPIError( f"Unexpected error during WebSocket handshake: {err}" @@ -220,7 +226,13 @@ async def connect_with_auth( except HomeAssistantAPIError: await client.close() raise - except Exception as err: + except ( + KeyError, + ValueError, + TypeError, + aiohttp.ClientError, + TimeoutError, + ) as err: await client.close() raise HomeAssistantAPIError( f"Unexpected error during WebSocket handshake: {err}" From b4f5252855efd4431ffdeba96a416ae9cae346ea Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 9 Apr 2026 13:55:40 +0200 Subject: [PATCH 14/16] Remove unused create_mountpoint from MountBindOptions The field was added but never used. The /run/supervisor host path is guaranteed to exist since HAOS creates it for the Supervisor container mount, so auto-creating the mountpoint is unnecessary. Co-Authored-By: Claude Opus 4.6 (1M context) --- supervisor/docker/const.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/supervisor/docker/const.py b/supervisor/docker/const.py index 42ead9cf99d..711bb8c0605 100644 --- a/supervisor/docker/const.py +++ b/supervisor/docker/const.py @@ -89,7 +89,6 @@ class MountBindOptions: propagation: PropagationMode | None = None read_only_non_recursive: bool | None = None - create_mountpoint: bool | None = None def to_dict(self) -> dict[str, Any]: """To dictionary representation.""" @@ -98,8 +97,6 @@ def to_dict(self) -> dict[str, Any]: out["Propagation"] = self.propagation.value if self.read_only_non_recursive is not None: out["ReadOnlyNonRecursive"] = self.read_only_non_recursive - if self.create_mountpoint is not None: - out["CreateMountpoint"] = self.create_mountpoint return out From b289d9ff352f14df7d7177eff9252531f62c829b Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 9 Apr 2026 13:58:10 +0200 Subject: [PATCH 15/16] Clear stale access token before raising on final retry Move token clear before the attempt check in connect_websocket so the stale token is always discarded, even when raising on the final attempt. Without this, the next call would reuse the cached bad token via _ensure_access_token's fast path, wasting a round-trip. Co-Authored-By: Claude Opus 4.6 (1M context) --- supervisor/homeassistant/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/supervisor/homeassistant/api.py b/supervisor/homeassistant/api.py index f72863f7ff4..0fe07516353 100644 --- a/supervisor/homeassistant/api.py +++ b/supervisor/homeassistant/api.py @@ -220,12 +220,12 @@ async def connect_websocket( max_msg_size=max_msg_size, ) except HomeAssistantAPIError: + self._access_token = None if attempt == 2: raise - self._access_token = None # Unreachable, but satisfies type checker - raise HomeAssistantAPIError("Failed to connect WebSocket") + raise RuntimeError("Unreachable") @asynccontextmanager async def make_request( From a314ed88c43026f8c57bccc4981c96efb018c4ea Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 9 Apr 2026 15:42:25 +0200 Subject: [PATCH 16/16] Add tests for Unix socket communication and Core API Add tests for the new Unix socket communication path and improve existing test coverage: - Version-based supports_unix_socket and env-based use_unix_socket - api_url/ws_url transport selection - Connection lifecycle: connected log after restart, ignoring unrelated container events - get_api_state/check_api_state parameterized across versions, responses, and error cases - make_request is_running guard and TCP flow with real token fetch - connect_websocket for both Unix and TCP (with token verification) - WSClient.connect/connect_with_auth handshake success, errors, cleanup on failure, and close with pending futures Consolidate existing tests into parameterized form and drop synthetic tests that covered very little. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/homeassistant/test_api.py | 438 ++++++++++++++++++++------ tests/homeassistant/test_websocket.py | 156 ++++++++- 2 files changed, 487 insertions(+), 107 deletions(-) diff --git a/tests/homeassistant/test_api.py b/tests/homeassistant/test_api.py index 35125ced3f1..9971d3b9342 100644 --- a/tests/homeassistant/test_api.py +++ b/tests/homeassistant/test_api.py @@ -1,65 +1,39 @@ """Test Home Assistant API.""" from contextlib import asynccontextmanager -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import hdrs from awesomeversion import AwesomeVersion import pytest from supervisor.coresys import CoreSys +from supervisor.docker.const import ContainerState +from supervisor.docker.monitor import DockerContainerStateEvent from supervisor.exceptions import HomeAssistantAPIError +from supervisor.homeassistant.api import APIState, HomeAssistantAPI +from supervisor.homeassistant.const import LANDINGPAGE +from tests.common import MockResponse -async def test_check_frontend_available_success(coresys: CoreSys): - """Test frontend availability check succeeds with valid HTML response.""" - coresys.homeassistant.version = AwesomeVersion("2025.8.0") - - mock_response = MagicMock() - mock_response.status = 200 - mock_response.headers = {hdrs.CONTENT_TYPE: "text/html; charset=utf-8"} - - @asynccontextmanager - async def mock_make_request(*args, **kwargs): - yield mock_response - - with patch.object( - type(coresys.homeassistant.api), "make_request", new=mock_make_request - ): - result = await coresys.homeassistant.api.check_frontend_available() - - assert result is True - +# --- check_frontend_available --- -async def test_check_frontend_available_wrong_status(coresys: CoreSys): - """Test frontend availability check fails with non-200 status.""" - coresys.homeassistant.version = AwesomeVersion("2025.8.0") - - mock_response = MagicMock() - mock_response.status = 404 - mock_response.headers = {hdrs.CONTENT_TYPE: "text/html"} - - @asynccontextmanager - async def mock_make_request(*args, **kwargs): - yield mock_response - with patch.object( - type(coresys.homeassistant.api), "make_request", new=mock_make_request - ): - result = await coresys.homeassistant.api.check_frontend_available() - - assert result is False - - -async def test_check_frontend_available_wrong_content_type( - coresys: CoreSys, caplog: pytest.LogCaptureFixture +@pytest.mark.parametrize( + ("status", "content_type", "expected"), + [ + (200, "text/html; charset=utf-8", True), + (404, "text/html", False), + (200, "application/json", False), + ], +) +async def test_check_frontend_available( + coresys: CoreSys, status: int, content_type: str, expected: bool ): - """Test frontend availability check fails with wrong content type.""" - coresys.homeassistant.version = AwesomeVersion("2025.8.0") - + """Test frontend availability based on HTTP status and content type.""" mock_response = MagicMock() - mock_response.status = 200 - mock_response.headers = {hdrs.CONTENT_TYPE: "application/json"} + mock_response.status = status + mock_response.headers = {hdrs.CONTENT_TYPE: content_type} @asynccontextmanager async def mock_make_request(*args, **kwargs): @@ -68,15 +42,11 @@ async def mock_make_request(*args, **kwargs): with patch.object( type(coresys.homeassistant.api), "make_request", new=mock_make_request ): - result = await coresys.homeassistant.api.check_frontend_available() - - assert result is False - assert "unexpected content type" in caplog.text + assert await coresys.homeassistant.api.check_frontend_available() is expected async def test_check_frontend_available_api_error(coresys: CoreSys): """Test frontend availability check handles API errors gracefully.""" - coresys.homeassistant.version = AwesomeVersion("2025.8.0") @asynccontextmanager async def mock_make_request(*args, **kwargs): @@ -86,15 +56,14 @@ async def mock_make_request(*args, **kwargs): with patch.object( type(coresys.homeassistant.api), "make_request", new=mock_make_request ): - result = await coresys.homeassistant.api.check_frontend_available() + assert await coresys.homeassistant.api.check_frontend_available() is False - assert result is False + +# --- get_config / get_core_state --- async def test_get_config_success(coresys: CoreSys): """Test get_config returns valid config dictionary.""" - coresys.homeassistant.version = AwesomeVersion("2025.8.0") - expected_config = { "latitude": 32.87336, "longitude": -117.22743, @@ -113,11 +82,7 @@ async def test_get_config_success(coresys: CoreSys): mock_response = MagicMock() mock_response.status = 200 - - async def mock_json(): - return expected_config - - mock_response.json = mock_json + mock_response.json = AsyncMock(return_value=expected_config) @asynccontextmanager async def mock_make_request(*_args, **_kwargs): @@ -126,22 +91,24 @@ async def mock_make_request(*_args, **_kwargs): with patch.object( type(coresys.homeassistant.api), "make_request", new=mock_make_request ): - result = await coresys.homeassistant.api.get_config() - - assert result == expected_config - - -async def test_get_config_returns_none(coresys: CoreSys): - """Test get_config raises error when None is returned.""" - coresys.homeassistant.version = AwesomeVersion("2025.8.0") - + assert await coresys.homeassistant.api.get_config() == expected_config + + +@pytest.mark.parametrize( + ("method", "bad_response", "match"), + [ + ("get_config", None, "No config received"), + ("get_config", ["not", "a", "dict"], "No config received"), + ("get_core_state", None, "No state received"), + ], +) +async def test_get_json_validation( + coresys: CoreSys, method: str, bad_response, match: str +): + """Test get_config/get_core_state raise on invalid responses.""" mock_response = MagicMock() mock_response.status = 200 - - async def mock_json(): - return None - - mock_response.json = mock_json + mock_response.json = AsyncMock(return_value=bad_response) @asynccontextmanager async def mock_make_request(*_args, **_kwargs): @@ -151,24 +118,14 @@ async def mock_make_request(*_args, **_kwargs): patch.object( type(coresys.homeassistant.api), "make_request", new=mock_make_request ), - pytest.raises( - HomeAssistantAPIError, match="No config received from Home Assistant API" - ), + pytest.raises(HomeAssistantAPIError, match=match), ): - await coresys.homeassistant.api.get_config() - + await getattr(coresys.homeassistant.api, method)() -async def test_get_config_returns_non_dict(coresys: CoreSys): - """Test get_config raises error when non-dict is returned.""" - coresys.homeassistant.version = AwesomeVersion("2025.8.0") - mock_response = MagicMock() - mock_response.status = 200 - - async def mock_json(): - return ["not", "a", "dict"] - - mock_response.json = mock_json +async def test_get_config_api_error(coresys: CoreSys): + """Test get_config propagates API errors.""" + mock_response = MagicMock(status=500) @asynccontextmanager async def mock_make_request(*_args, **_kwargs): @@ -178,30 +135,301 @@ async def mock_make_request(*_args, **_kwargs): patch.object( type(coresys.homeassistant.api), "make_request", new=mock_make_request ), - pytest.raises( - HomeAssistantAPIError, match="No config received from Home Assistant API" - ), + pytest.raises(HomeAssistantAPIError, match="500"), ): await coresys.homeassistant.api.get_config() -async def test_get_config_api_error(coresys: CoreSys): - """Test get_config propagates API errors from underlying _get_json call.""" +# --- supports_unix_socket / use_unix_socket --- + + +@pytest.mark.parametrize( + ("version", "expected"), + [ + ("2026.4.0", True), + ("2024.1.0", False), + (LANDINGPAGE, False), + ], +) +async def test_supports_unix_socket(coresys: CoreSys, version: str, expected: bool): + """Test supports_unix_socket based on Core version.""" + coresys.homeassistant.version = AwesomeVersion(version) + assert coresys.homeassistant.api.supports_unix_socket is expected + + +@pytest.mark.parametrize( + ("version", "env", "expected"), + [ + ("2024.1.0", [], False), + ("2026.4.0", ["SUPERVISOR_CORE_API_SOCKET=/run/supervisor/core.sock"], True), + ("2026.4.0", ["TZ=UTC", "SUPERVISOR_TOKEN=abc"], False), + ], +) +async def test_use_unix_socket( + coresys: CoreSys, version: str, env: list[str], expected: bool +): + """Test use_unix_socket based on version and container env.""" + coresys.homeassistant.version = AwesomeVersion(version) + # pylint: disable-next=protected-access + coresys.homeassistant.core.instance._meta = {"Config": {"Env": env}} + assert coresys.homeassistant.api.use_unix_socket is expected + + +# --- api_url / ws_url --- + + +@pytest.mark.parametrize( + ("use_unix", "expected_api_url", "expected_ws_url"), + [ + (True, "http://localhost", "ws://localhost/api/websocket"), + (False, "http://172.30.32.1:8123", "ws://172.30.32.1:8123/api/websocket"), + ], +) +async def test_api_and_ws_urls( + coresys: CoreSys, use_unix: bool, expected_api_url: str, expected_ws_url: str +): + """Test api_url and ws_url for Unix socket and TCP transports.""" + with patch.object(type(coresys.homeassistant.api), "use_unix_socket", use_unix): + assert coresys.homeassistant.api.api_url == expected_api_url + assert coresys.homeassistant.api.ws_url == expected_ws_url + + +# --- connection lifecycle --- + + +@pytest.fixture +def real_get_api_state(coresys: CoreSys): + """Restore real get_api_state (coresys fixture mocks it).""" + api = coresys.homeassistant.api + api.get_api_state = type(api).get_api_state.__get__(api) + return api + + +async def test_connected_log_after_container_restart( + coresys: CoreSys, + real_get_api_state: HomeAssistantAPI, + caplog: pytest.LogCaptureFixture, +): + """Test 'Connected to Core' log reappears after container stop and reconnect.""" + api = coresys.homeassistant.api coresys.homeassistant.version = AwesomeVersion("2025.8.0") + api.get_core_state = AsyncMock( + return_value={"state": "RUNNING", "recorder_state": {}} + ) + + # First connection logs + with patch.object(type(api), "use_unix_socket", False): + await api.get_api_state() + assert "Connected to Core via TCP" in caplog.text + + # Container stops + caplog.clear() + await api.container_state_changed( + DockerContainerStateEvent( + name="homeassistant", + state=ContainerState.STOPPED, + id="abc123", + time=1234567890, + ) + ) + + # Reconnect logs again + with patch.object(type(api), "use_unix_socket", False): + await api.get_api_state() + assert "Connected to Core via TCP" in caplog.text + + +async def test_container_state_changed_ignores_other_containers( + coresys: CoreSys, + real_get_api_state: HomeAssistantAPI, + caplog: pytest.LogCaptureFixture, +): + """Test container_state_changed ignores events from other containers.""" + api = coresys.homeassistant.api + coresys.homeassistant.version = AwesomeVersion("2025.8.0") + api.get_core_state = AsyncMock( + return_value={"state": "RUNNING", "recorder_state": {}} + ) + + # First connection + with patch.object(type(api), "use_unix_socket", False): + await api.get_api_state() + assert "Connected to Core via TCP" in caplog.text + + # Other container stops — should not reset + caplog.clear() + await api.container_state_changed( + DockerContainerStateEvent( + name="addon_local_ssh", + state=ContainerState.STOPPED, + id="abc123", + time=1234567890, + ) + ) + + with patch.object(type(api), "use_unix_socket", False): + await api.get_api_state() + # Should NOT log again since connection state wasn't reset + assert "Connected to Core" not in caplog.text + + +# --- get_api_state / check_api_state --- + + +@pytest.mark.parametrize( + ("version", "core_state_response", "expected_state", "expected_check"), + [ + (LANDINGPAGE, None, None, False), + (None, None, None, False), + ( + "2025.8.0", + {"state": "RUNNING", "recorder_state": {}}, + APIState("RUNNING", False), + True, + ), + ( + "2025.8.0", + {"state": "NOT_RUNNING", "recorder_state": {}}, + APIState("NOT_RUNNING", False), + False, + ), + ( + "2025.8.0", + HomeAssistantAPIError("Connection failed"), + None, + False, + ), + ], +) +async def test_get_api_state( + coresys: CoreSys, + real_get_api_state: HomeAssistantAPI, + version: str | None, + core_state_response: dict | Exception | None, + expected_state: APIState | None, + expected_check: bool, +): + """Test get_api_state and check_api_state for various scenarios.""" + coresys.homeassistant.version = ( + AwesomeVersion(version) if version and version != LANDINGPAGE else version + ) + if isinstance(core_state_response, Exception): + coresys.homeassistant.api.get_core_state = AsyncMock( + side_effect=core_state_response + ) + elif core_state_response is not None: + coresys.homeassistant.api.get_core_state = AsyncMock( + return_value=core_state_response + ) - mock_response = MagicMock() - mock_response.status = 500 + with patch.object(type(coresys.homeassistant.api), "use_unix_socket", False): + assert await coresys.homeassistant.api.get_api_state() == expected_state + assert await coresys.homeassistant.api.check_api_state() is expected_check + + +# --- make_request --- + + +async def test_make_request_not_running(coresys: CoreSys): + """Test make_request raises when Core container is not running.""" + coresys.homeassistant.core.instance.is_running = AsyncMock(return_value=False) + + with pytest.raises(HomeAssistantAPIError, match="not running"): + async with coresys.homeassistant.api.make_request("get", "api/test"): + pass + + +@pytest.mark.usefixtures("websession") +async def test_make_request_tcp_with_token_fetch(coresys: CoreSys): + """Test make_request fetches token via /auth/token and makes the request.""" + api = coresys.homeassistant.api + + # Mock /auth/token POST + token_resp = MockResponse() + token_resp.json = AsyncMock( + return_value={"access_token": "test_token", "expires_in": 1800} + ) + coresys.websession.post = MagicMock(return_value=token_resp) + + # Mock the actual API request + api_resp = MagicMock(status=200) @asynccontextmanager - async def mock_make_request(*_args, **_kwargs): - yield mock_response + async def mock_request(*_args, **_kwargs): + yield api_resp + + coresys.websession.request = mock_request + + with patch.object(type(api), "use_unix_socket", False): + async with api.make_request("get", "api/test") as resp: + assert resp.status == 200 + + # Verify token was fetched + coresys.websession.post.assert_called_once() + + +@pytest.mark.usefixtures("websession") +async def test_make_request_tcp_timeout(coresys: CoreSys): + """Test make_request wraps TimeoutError.""" + api = coresys.homeassistant.api + coresys.websession.request = MagicMock(side_effect=TimeoutError("timed out")) with ( - patch.object( - type(coresys.homeassistant.api), "make_request", new=mock_make_request - ), - pytest.raises( - HomeAssistantAPIError, match="Home Assistant Core API return 500" - ), + patch.object(type(api), "use_unix_socket", False), + patch.object(api, "_ensure_access_token", new_callable=AsyncMock), + pytest.raises(HomeAssistantAPIError, match="timed out"), ): - await coresys.homeassistant.api.get_config() + async with api.make_request("get", "api/test"): + pass + + +# --- connect_websocket --- + + +async def test_connect_websocket_unix(coresys: CoreSys): + """Test connect_websocket uses WSClient.connect for Unix socket.""" + coresys.homeassistant.core.instance.is_running = AsyncMock(return_value=True) + mock_ws_client = MagicMock() + with ( + patch.object(type(coresys.homeassistant.api), "use_unix_socket", True), + patch( + "supervisor.homeassistant.api.WSClient.connect", + new_callable=AsyncMock, + return_value=mock_ws_client, + ) as mock_connect, + ): + result = await coresys.homeassistant.api.connect_websocket() + + assert result is mock_ws_client + mock_connect.assert_called_once() + + +@pytest.mark.usefixtures("websession") +async def test_connect_websocket_tcp(coresys: CoreSys): + """Test connect_websocket fetches token and connects with auth for TCP.""" + api = coresys.homeassistant.api + mock_ws_client = MagicMock() + + # Mock the /auth/token endpoint to return a valid token + token_resp = MockResponse() + token_resp.json = AsyncMock( + return_value={"access_token": "fresh_token", "expires_in": 1800} + ) + coresys.websession.post = MagicMock(return_value=token_resp) + + with ( + patch.object(type(api), "use_unix_socket", False), + patch( + "supervisor.homeassistant.api.WSClient.connect_with_auth", + new_callable=AsyncMock, + return_value=mock_ws_client, + ) as mock_connect, + ): + result = await api.connect_websocket() + + assert result is mock_ws_client + # Verify token was fetched + coresys.websession.post.assert_called_once() + # Verify connect_with_auth was called with the fresh token + mock_connect.assert_called_once() + assert mock_connect.call_args.args[2] == "fresh_token" diff --git a/tests/homeassistant/test_websocket.py b/tests/homeassistant/test_websocket.py index acc58209582..2ee8b2d33cd 100644 --- a/tests/homeassistant/test_websocket.py +++ b/tests/homeassistant/test_websocket.py @@ -2,14 +2,16 @@ # pylint: disable=import-error import asyncio -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch +import aiohttp import pytest from supervisor.const import CoreState from supervisor.coresys import CoreSys -from supervisor.exceptions import HomeAssistantWSConnectionError +from supervisor.exceptions import HomeAssistantAPIError, HomeAssistantWSConnectionError from supervisor.homeassistant.const import WSEvent, WSType +from supervisor.homeassistant.websocket import WSClient async def test_send_command(coresys: CoreSys, ha_ws_client: AsyncMock): @@ -106,3 +108,153 @@ async def test_send_command_during_shutdown(coresys: CoreSys, ha_ws_client: Asyn await coresys.homeassistant.websocket.async_send_command({"type": "test"}) ha_ws_client.async_send_command.assert_not_called() + + +# --- WSClient --- + + +def _mock_ws_client(messages: list[dict]) -> MagicMock: + """Create a mock aiohttp WebSocket client that returns messages in sequence.""" + client = AsyncMock(spec=aiohttp.ClientWebSocketResponse) + client.receive_json = AsyncMock(side_effect=messages) + client.send_json = AsyncMock() + client.close = AsyncMock() + client.closed = False + return client + + +async def test_ws_connect_error(): + """Test _ws_connect wraps ClientConnectorError.""" + session = AsyncMock() + session.ws_connect = AsyncMock( + side_effect=aiohttp.ClientConnectorError( + MagicMock(), OSError("Connection refused") + ) + ) + + with pytest.raises(HomeAssistantWSConnectionError, match="Can't connect"): + await WSClient._ws_connect(session, "ws://localhost/api/websocket") + + +async def test_connect_unix_success(): + """Test WSClient.connect succeeds with auth_ok.""" + session = AsyncMock() + ws = _mock_ws_client([{"type": "auth_ok", "ha_version": "2026.4.0"}]) + session.ws_connect = AsyncMock(return_value=ws) + + client = await WSClient.connect(session, "ws://localhost/api/websocket") + assert client.ha_version == "2026.4.0" + assert client.connected is True + ws.close.assert_not_called() + + +async def test_connect_unix_unexpected_message(): + """Test WSClient.connect raises and closes on unexpected message.""" + session = AsyncMock() + ws = _mock_ws_client([{"type": "auth_required", "ha_version": "2026.4.0"}]) + session.ws_connect = AsyncMock(return_value=ws) + + with pytest.raises(HomeAssistantAPIError, match="Expected auth_ok"): + await WSClient.connect(session, "ws://localhost/api/websocket") + ws.close.assert_called_once() + + +async def test_connect_unix_bad_json(): + """Test WSClient.connect wraps ValueError from bad JSON.""" + session = AsyncMock() + ws = AsyncMock(spec=aiohttp.ClientWebSocketResponse) + ws.receive_json = AsyncMock(side_effect=ValueError("bad json")) + ws.close = AsyncMock() + session.ws_connect = AsyncMock(return_value=ws) + + with pytest.raises(HomeAssistantAPIError, match="Unexpected error"): + await WSClient.connect(session, "ws://localhost/api/websocket") + ws.close.assert_called_once() + + +async def test_connect_with_auth_success(): + """Test WSClient.connect_with_auth succeeds with auth handshake.""" + session = AsyncMock() + ws = _mock_ws_client( + [ + {"type": "auth_required", "ha_version": "2026.4.0"}, + {"type": "auth_ok", "ha_version": "2026.4.0"}, + ] + ) + session.ws_connect = AsyncMock(return_value=ws) + + client = await WSClient.connect_with_auth( + session, "ws://localhost/api/websocket", "test_token" + ) + assert client.ha_version == "2026.4.0" + ws.send_json.assert_called_once() + ws.close.assert_not_called() + + +async def test_connect_with_auth_unexpected_first_message(): + """Test connect_with_auth raises on unexpected first message.""" + session = AsyncMock() + ws = _mock_ws_client([{"type": "auth_ok", "ha_version": "2026.4.0"}]) + session.ws_connect = AsyncMock(return_value=ws) + + with pytest.raises(HomeAssistantAPIError, match="Expected auth_required"): + await WSClient.connect_with_auth( + session, "ws://localhost/api/websocket", "test_token" + ) + ws.close.assert_called_once() + + +async def test_connect_with_auth_rejected(): + """Test connect_with_auth raises on auth rejection.""" + session = AsyncMock() + ws = _mock_ws_client( + [ + {"type": "auth_required", "ha_version": "2026.4.0"}, + {"type": "auth_invalid", "message": "Invalid password"}, + ] + ) + session.ws_connect = AsyncMock(return_value=ws) + + with pytest.raises(HomeAssistantAPIError, match="AUTH NOT OK"): + await WSClient.connect_with_auth( + session, "ws://localhost/api/websocket", "bad_token" + ) + ws.close.assert_called_once() + + +async def test_connect_with_auth_missing_key(): + """Test connect_with_auth wraps KeyError from missing keys.""" + session = AsyncMock() + ws = _mock_ws_client([{"no_type_key": "oops"}]) + session.ws_connect = AsyncMock(return_value=ws) + + with pytest.raises(HomeAssistantAPIError, match="Unexpected error"): + await WSClient.connect_with_auth( + session, "ws://localhost/api/websocket", "token" + ) + ws.close.assert_called_once() + + +async def test_ws_client_close(): + """Test WSClient.close cancels pending futures and closes connection.""" + ws = AsyncMock(spec=aiohttp.ClientWebSocketResponse) + ws.closed = False + ws.close = AsyncMock() + + client = WSClient.__new__(WSClient) + client.ha_version = "2026.4.0" + client.client = ws + client._message_id = 0 + client._futures = {} + + # Add a pending future + loop = asyncio.get_running_loop() + future = loop.create_future() + client._futures[1] = future + + await client.close() + + assert future.done() + with pytest.raises(HomeAssistantWSConnectionError): + future.result() + ws.close.assert_called_once()