1
0
mirror of https://github.com/home-assistant/supervisor.git synced 2026-02-15 07:27:13 +00:00

Simplify HomeAssistantWebSocket and raise on connection errors (#6553)

* Raise HomeAssistantWSError when Core WebSocket is unreachable

Previously, async_send_command silently returned None when Home Assistant
Core was not reachable, leading to misleading error messages downstream
(e.g. "returned invalid response of None instead of a list of users").

Refactor _can_send to _ensure_connected which now raises
HomeAssistantWSError on connection failures while still returning False
for silent-skip cases (shutdown, unsupported version). async_send_message
catches the exception to preserve fire-and-forget behavior.

Update callers that don't handle HomeAssistantWSError: _hardware_events
and addon auto-update in tasks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Simplify HomeAssistantWebSocket command/message distinction

The WebSocket layer had a confusing split between "messages" (fire-and-forget)
and "commands" (request/response) that didn't reflect Home Assistant Core's
architecture where everything is just a WS command.

- Remove dead WSClient.async_send_message (never called)
- Rename async_send_message → _async_send_command (private, fire-and-forget)
- Rename send_message → send_command (sync wrapper)
- Simplify _ensure_connected: drop message param, always raise on failure
- Simplify async_send_command: always raise on connection errors
- Remove MIN_VERSION gating (minimum supported Core is now 2024.2+)
- Remove begin_backup/end_backup version guards for Core < 2022.1.0
- Add debug logging for silently ignored connection errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Wait for Core to come up before backup

This is crucial since the WebSocket command to Core now fails with the
new error handling if Core is not running yet.

* Wait for Core install job instead

* Use CLI to fetch jobs instead of Supervisor API

The Supervisor API needs authentication token, which we have not
available at this point in the workflow. Instead of fetching the token,
we can use the CLI, which is available in the container.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Stefan Agner
2026-02-12 09:20:23 +01:00
committed by GitHub
parent 7ae14b09a7
commit da800b8889
9 changed files with 142 additions and 101 deletions

View File

@@ -323,6 +323,35 @@ jobs:
docker logs --tail 50 hassio_supervisor
exit 1
# Wait for Core to come up so subsequent steps (backup, addon install) succeed.
# On first startup, Supervisor installs Core via the "home_assistant_core_install"
# job (which pulls the image and then starts Core). Jobs with cleanup=True are
# removed from the jobs list once done, so we poll until it's gone.
- name: Wait for Core to be started
run: |
echo "Waiting for Home Assistant Core to be installed and started..."
timeout=300
elapsed=0
while [ $elapsed -lt $timeout ]; do
jobs=$(docker exec hassio_cli ha jobs info --no-progress --raw-json | jq -r '.data.jobs[] | select(.name == "home_assistant_core_install" and .done == false) | .name' 2>/dev/null)
if [ -z "$jobs" ]; then
echo "Home Assistant Core install/start complete (took ${elapsed}s)"
exit 0
fi
if [ $((elapsed % 15)) -eq 0 ]; then
echo "Core still installing... (${elapsed}s/${timeout}s)"
fi
sleep 5
elapsed=$((elapsed + 5))
done
echo "ERROR: Home Assistant Core failed to install/start within ${timeout}s"
docker logs --tail 50 hassio_supervisor
exit 1
- name: Check the Supervisor
run: |
echo "Checking supervisor info"

View File

@@ -360,15 +360,23 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes):
):
return
configuration: (
dict[str, Any] | None
) = await self.sys_homeassistant.websocket.async_send_command(
{ATTR_TYPE: "get_config"}
)
try:
configuration: (
dict[str, Any] | None
) = await self.sys_homeassistant.websocket.async_send_command(
{ATTR_TYPE: "get_config"}
)
except HomeAssistantWSError as err:
_LOGGER.warning(
"Can't get Home Assistant Core configuration: %s. Not sending hardware events to Home Assistant Core.",
err,
)
return
if not configuration or "usb" not in configuration.get("components", []):
return
self.sys_homeassistant.websocket.send_message({ATTR_TYPE: "usb/scan"})
self.sys_homeassistant.websocket.send_command({ATTR_TYPE: "usb/scan"})
@Job(name="home_assistant_module_begin_backup")
async def begin_backup(self) -> None:

View File

@@ -30,12 +30,6 @@ from ..exceptions import (
from ..utils.json import json_dumps
from .const import CLOSING_STATES, WSEvent, WSType
MIN_VERSION = {
WSType.SUPERVISOR_EVENT: "2021.2.4",
WSType.BACKUP_START: "2022.1.0",
WSType.BACKUP_END: "2022.1.0",
}
_LOGGER: logging.Logger = logging.getLogger(__name__)
T = TypeVar("T")
@@ -71,15 +65,6 @@ class WSClient:
if not self._client.closed:
await self._client.close()
async def async_send_message(self, message: dict[str, Any]) -> None:
"""Send a websocket message, don't wait for response."""
self._message_id += 1
_LOGGER.debug("Sending: %s", message)
try:
await self._client.send_json(message, dumps=json_dumps)
except ConnectionError as err:
raise HomeAssistantWSConnectionError(str(err)) from err
async def async_send_command(self, message: dict[str, Any]) -> T | None:
"""Send a websocket message, and return the response."""
self._message_id += 1
@@ -191,7 +176,7 @@ class HomeAssistantWebSocket(CoreSysAttributes):
"""Process queue once supervisor is running."""
if reference == CoreState.RUNNING:
for msg in self._queue:
await self.async_send_message(msg)
await self._async_send_command(msg)
self._queue.clear()
@@ -212,38 +197,26 @@ class HomeAssistantWebSocket(CoreSysAttributes):
self.sys_create_task(client.start_listener())
return client
async def _can_send(self, message: dict[str, Any]) -> bool:
"""Determine if we can use WebSocket messages."""
async def _ensure_connected(self) -> None:
"""Ensure WebSocket connection is ready.
Raises HomeAssistantWSError if unable to connect.
"""
if self.sys_core.state in CLOSING_STATES:
return False
raise HomeAssistantWSError(
"WebSocket not available, system is shutting down"
)
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():
# No core access, don't try.
return False
if not self._client:
self._client = await self._get_ws_client()
if not self._client.connected:
self._client = await self._get_ws_client()
message_type = message.get("type")
if (
message_type is not None
and message_type in MIN_VERSION
and self._client.ha_version < MIN_VERSION[message_type]
):
_LOGGER.info(
"WebSocket command %s is not supported until core-%s. Ignoring WebSocket message.",
message_type,
MIN_VERSION[message_type],
raise HomeAssistantWSError(
"Can't connect to Home Assistant Core WebSocket, the API is not reachable"
)
return False
return True
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."""
@@ -251,53 +224,61 @@ class HomeAssistantWebSocket(CoreSysAttributes):
BusEvent.SUPERVISOR_STATE_CHANGE, self._process_queue
)
async def async_send_message(self, message: dict[str, Any]) -> None:
"""Send a message with the WS client."""
# Only commands allowed during startup as those tell Home Assistant to do something.
# Messages may cause clients to make follow-up API calls so those wait.
async def _async_send_command(self, message: dict[str, Any]) -> None:
"""Send a fire-and-forget command via WebSocket.
Queues messages during startup. Silently handles connection errors.
"""
if self.sys_core.state in STARTING_STATES:
self._queue.append(message)
_LOGGER.debug("Queuing message until startup has completed: %s", message)
return
if not await self._can_send(message):
try:
await self._ensure_connected()
except HomeAssistantWSError as err:
_LOGGER.debug("Can't send WebSocket command: %s", err)
return
# _ensure_connected guarantees self._client is set
assert self._client
try:
if self._client:
await self._client.async_send_command(message)
except HomeAssistantWSConnectionError:
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
async def async_send_command(self, message: dict[str, Any]) -> T | None:
"""Send a command with the WS client and wait for the response."""
if not await self._can_send(message):
return None
"""Send a command and return the response.
Raises HomeAssistantWSError if unable to connect to Home Assistant Core.
"""
await self._ensure_connected()
# _ensure_connected guarantees self._client is set
assert self._client
try:
if self._client:
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
raise
return None
def send_message(self, message: dict[str, Any]) -> None:
"""Send a supervisor/event message."""
def send_command(self, message: dict[str, Any]) -> None:
"""Send a fire-and-forget command via WebSocket."""
if self.sys_core.state in CLOSING_STATES:
return
self.sys_create_task(self.async_send_message(message))
self.sys_create_task(self._async_send_command(message))
async def async_supervisor_event_custom(
self, event: WSEvent, extra_data: dict[str, Any] | None = None
) -> None:
"""Send a supervisor/event message to Home Assistant with custom data."""
try:
await self.async_send_message(
await self._async_send_command(
{
ATTR_TYPE: WSType.SUPERVISOR_EVENT,
ATTR_DATA: {

View File

@@ -13,6 +13,7 @@ from ..exceptions import (
AddonsError,
BackupFileNotFoundError,
HomeAssistantError,
HomeAssistantWSError,
ObserverError,
SupervisorUpdateError,
)
@@ -152,7 +153,13 @@ class Tasks(CoreSysAttributes):
"Sending update add-on WebSocket command to Home Assistant Core: %s",
message,
)
await self.sys_homeassistant.websocket.async_send_command(message)
try:
await self.sys_homeassistant.websocket.async_send_command(message)
except HomeAssistantWSError as err:
_LOGGER.warning(
"Could not send add-on update command to Home Assistant Core: %s",
err,
)
@Job(
name="tasks_update_supervisor",

View File

@@ -1186,7 +1186,9 @@ async def test_restore_homeassistant_adds_env(
with (
patch.object(HomeAssistantCore, "_block_till_run"),
patch.object(
HomeAssistantWebSocket, "async_send_message", new=mock_async_send_message
HomeAssistantWebSocket,
"_async_send_command",
new=mock_async_send_message,
),
):
resp = await api_client.post(

View File

@@ -50,7 +50,7 @@ async def test_load(
assert coresys.homeassistant.secrets.secrets == {"hello": "world"}
await coresys.core.set_state(CoreState.SETUP)
await coresys.homeassistant.websocket.async_send_message({"lorem": "ipsum"})
await coresys.homeassistant.websocket._async_send_command({"lorem": "ipsum"})
ha_ws_client.async_send_command.assert_not_called()
await coresys.core.set_state(CoreState.RUNNING)
@@ -93,7 +93,7 @@ async def test_begin_backup_ws_error(coresys: CoreSys):
HomeAssistantWSConnectionError("Connection was closed")
)
with (
patch.object(HomeAssistantWebSocket, "_can_send", return_value=True),
patch.object(HomeAssistantWebSocket, "_ensure_connected", return_value=None),
pytest.raises(
HomeAssistantBackupError,
match="Preparing backup of Home Assistant Core failed. Failed to inform HA Core: Connection was closed.",
@@ -108,7 +108,7 @@ async def test_end_backup_ws_error(coresys: CoreSys, caplog: pytest.LogCaptureFi
coresys.homeassistant.websocket._client.async_send_command.side_effect = (
HomeAssistantWSConnectionError("Connection was closed")
)
with patch.object(HomeAssistantWebSocket, "_can_send", return_value=True):
with patch.object(HomeAssistantWebSocket, "_ensure_connected", return_value=None):
await coresys.homeassistant.end_backup()
assert (

View File

@@ -2,18 +2,18 @@
# pylint: disable=import-error
import asyncio
import logging
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, patch
from awesomeversion import AwesomeVersion
import pytest
from supervisor.const import CoreState
from supervisor.coresys import CoreSys
from supervisor.exceptions import HomeAssistantWSError
from supervisor.homeassistant.const import WSEvent, WSType
async def test_send_command(coresys: CoreSys, ha_ws_client: AsyncMock):
"""Test websocket error on listen."""
"""Test sending a command returns a response."""
await coresys.homeassistant.websocket.async_send_command({"type": "test"})
ha_ws_client.async_send_command.assert_called_with({"type": "test"})
@@ -32,30 +32,10 @@ async def test_send_command(coresys: CoreSys, ha_ws_client: AsyncMock):
)
async def test_send_command_old_core_version(
coresys: CoreSys, ha_ws_client: AsyncMock, caplog
async def test_fire_and_forget_during_startup(
coresys: CoreSys, ha_ws_client: AsyncMock
):
"""Test websocket error on listen."""
caplog.set_level(logging.INFO)
ha_ws_client.ha_version = AwesomeVersion("1970.1.1")
await coresys.homeassistant.websocket.async_send_command(
{"type": "supervisor/event"}
)
assert (
"WebSocket command supervisor/event is not supported until core-2021.2.4"
in caplog.text
)
await coresys.homeassistant.websocket.async_supervisor_update_event(
"test", {"lorem": "ipsum"}
)
ha_ws_client.async_send_command.assert_not_called()
async def test_send_message_during_startup(coresys: CoreSys, ha_ws_client: AsyncMock):
"""Test websocket messages queue during startup."""
"""Test fire-and-forget commands queue during startup and replay when running."""
await coresys.homeassistant.websocket.load()
await coresys.core.set_state(CoreState.SETUP)
@@ -92,3 +72,37 @@ async def test_send_message_during_startup(coresys: CoreSys, ha_ws_client: Async
"test", {"lorem": "ipsum"}
)
ha_ws_client.async_send_command.assert_not_called()
async def test_send_command_core_not_reachable(
coresys: CoreSys, ha_ws_client: AsyncMock
):
"""Test async_send_command raises when Core API is not reachable."""
ha_ws_client.connected = False
with (
patch.object(coresys.homeassistant.api, "check_api_state", return_value=False),
pytest.raises(HomeAssistantWSError, match="not reachable"),
):
await coresys.homeassistant.websocket.async_send_command({"type": "test"})
ha_ws_client.async_send_command.assert_not_called()
async def test_fire_and_forget_core_not_reachable(
coresys: CoreSys, ha_ws_client: AsyncMock
):
"""Test fire-and-forget command silently skips when Core API is not reachable."""
ha_ws_client.connected = False
with patch.object(coresys.homeassistant.api, "check_api_state", return_value=False):
await coresys.homeassistant.websocket._async_send_command({"type": "test"})
ha_ws_client.async_send_command.assert_not_called()
async def test_send_command_during_shutdown(coresys: CoreSys, ha_ws_client: AsyncMock):
"""Test async_send_command raises during shutdown."""
await coresys.core.set_state(CoreState.SHUTDOWN)
with pytest.raises(HomeAssistantWSError, match="shutting down"):
await coresys.homeassistant.websocket.async_send_command({"type": "test"})
ha_ws_client.async_send_command.assert_not_called()

View File

@@ -49,7 +49,7 @@ async def test_connectivity_events(coresys: CoreSys, force: bool):
await asyncio.sleep(0)
with patch.object(
type(coresys.homeassistant.websocket), "async_send_message"
type(coresys.homeassistant.websocket), "_async_send_command"
) as send_message:
await coresys.host.network.check_connectivity(force=force)
await asyncio.sleep(0)

View File

@@ -312,7 +312,7 @@ async def test_resolution_apply_suggestion_multiple_copies(coresys: CoreSys):
async def test_events_on_unsupported_changed(coresys: CoreSys):
"""Test events fired when unsupported changes."""
with patch.object(
type(coresys.homeassistant.websocket), "async_send_message"
type(coresys.homeassistant.websocket), "_async_send_command"
) as send_message:
# Marking system as unsupported tells HA
assert coresys.resolution.unsupported == []
@@ -376,7 +376,7 @@ async def test_events_on_unsupported_changed(coresys: CoreSys):
async def test_events_on_unhealthy_changed(coresys: CoreSys):
"""Test events fired when unhealthy changes."""
with patch.object(
type(coresys.homeassistant.websocket), "async_send_message"
type(coresys.homeassistant.websocket), "_async_send_command"
) as send_message:
# Marking system as unhealthy tells HA
assert coresys.resolution.unhealthy == []
@@ -415,7 +415,7 @@ async def test_events_on_unhealthy_changed(coresys: CoreSys):
async def test_dismiss_issue_removes_orphaned_suggestions(coresys: CoreSys):
"""Test dismissing an issue also removes any suggestions which have been orphaned."""
with patch.object(
type(coresys.homeassistant.websocket), "async_send_message"
type(coresys.homeassistant.websocket), "_async_send_command"
) as send_message:
coresys.resolution.create_issue(
IssueType.MOUNT_FAILED,