Skip to content

Commit 9b4e3ba

Browse files
committed
Merge tag '2025.10.0'
2 parents 1bc67a4 + 78a2e15 commit 9b4e3ba

File tree

23 files changed

+399
-106
lines changed

23 files changed

+399
-106
lines changed

.github/workflows/builder.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ jobs:
150150
151151
- name: Login to GitHub Container Registry
152152
if: needs.init.outputs.publish == 'true'
153-
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
153+
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
154154
with:
155155
registry: ghcr.io
156156
username: ${{ github.repository_owner }}

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ brotli==1.1.0
88
ciso8601==2.3.3
99
colorlog==6.9.0
1010
cpe==1.3.1
11-
cryptography==46.0.1
11+
cryptography==46.0.2
1212
debugpy==1.8.17
1313
deepmerge==2.0
1414
dirhash==0.5.0

supervisor/addons/addon.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@
7272
AddonsJobError,
7373
ConfigurationFileError,
7474
DockerError,
75-
HomeAssistantAPIError,
7675
HostAppArmorError,
7776
)
7877
from ..hardware.data import Device
@@ -842,8 +841,7 @@ def cleanup_config_and_audio():
842841
# Cleanup Ingress panel from sidebar
843842
if self.ingress_panel:
844843
self.ingress_panel = False
845-
with suppress(HomeAssistantAPIError):
846-
await self.sys_ingress.update_hass_panel(self)
844+
await self.sys_ingress.update_hass_panel(self)
847845

848846
# Cleanup Ingress dynamic port assignment
849847
need_ingress_token_cleanup = False

supervisor/addons/manager.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
CoreDNSError,
2121
DockerError,
2222
HassioError,
23-
HomeAssistantAPIError,
2423
)
2524
from ..jobs.decorator import Job, JobCondition
2625
from ..resolution.const import ContextType, IssueType, SuggestionType
@@ -351,8 +350,7 @@ async def restore(
351350
# Update ingress
352351
if had_ingress != addon.ingress_panel:
353352
await self.sys_ingress.reload()
354-
with suppress(HomeAssistantAPIError):
355-
await self.sys_ingress.update_hass_panel(addon)
353+
await self.sys_ingress.update_hass_panel(addon)
356354

357355
return wait_for_start
358356

supervisor/api/proxy.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,10 @@ async def _api_client(
7777
yield resp
7878
return
7979

80-
except HomeAssistantAuthError:
81-
_LOGGER.error("Authenticate error on API for request %s", path)
82-
except HomeAssistantAPIError:
83-
_LOGGER.error("Error on API for request %s", path)
80+
except HomeAssistantAuthError as err:
81+
_LOGGER.error("Authenticate error on API for request %s: %s", path, err)
82+
except HomeAssistantAPIError as err:
83+
_LOGGER.error("Error on API for request %s: %s", path, err)
8484
except aiohttp.ClientError as err:
8585
_LOGGER.error("Client error on API %s request %s", path, err)
8686
except TimeoutError:

supervisor/auth.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,8 @@ async def _backend_login(self, addon: Addon, username: str, password: str) -> bo
132132
_LOGGER.warning("Unauthorized login for '%s'", username)
133133
await self._dismatch_cache(username, password)
134134
return False
135-
except HomeAssistantAPIError:
136-
_LOGGER.error("Can't request auth on Home Assistant!")
135+
except HomeAssistantAPIError as err:
136+
_LOGGER.error("Can't request auth on Home Assistant: %s", err)
137137
finally:
138138
self._running.pop(username, None)
139139

@@ -152,8 +152,8 @@ async def change_password(self, username: str, password: str) -> None:
152152
return
153153

154154
_LOGGER.warning("The user '%s' is not registered", username)
155-
except HomeAssistantAPIError:
156-
_LOGGER.error("Can't request password reset on Home Assistant!")
155+
except HomeAssistantAPIError as err:
156+
_LOGGER.error("Can't request password reset on Home Assistant: %s", err)
157157

158158
raise AuthPasswordResetError()
159159

supervisor/discovery/__init__.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
from contextlib import suppress
65
import logging
76
from typing import TYPE_CHECKING, Any
87
from uuid import uuid4
@@ -119,7 +118,7 @@ async def _push_discovery(self, message: Message, command: str) -> None:
119118
data = attr.asdict(message)
120119
data.pop(ATTR_CONFIG)
121120

122-
with suppress(HomeAssistantAPIError):
121+
try:
123122
async with self.sys_homeassistant.api.make_request(
124123
command,
125124
f"api/hassio_push/discovery/{message.uuid}",
@@ -128,5 +127,5 @@ async def _push_discovery(self, message: Message, command: str) -> None:
128127
):
129128
_LOGGER.info("Discovery %s message send", message.uuid)
130129
return
131-
132-
_LOGGER.warning("Discovery %s message fail", message.uuid)
130+
except HomeAssistantAPIError as err:
131+
_LOGGER.error("Discovery %s message failed: %s", message.uuid, err)

supervisor/docker/manager.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -326,11 +326,19 @@ def run(
326326
if name:
327327
cidfile_path = self.coresys.config.path_cid_files / f"{name}.cid"
328328

329-
# Remove the file if it exists e.g. as a leftover from unclean shutdown
330-
if cidfile_path.is_file():
331-
with suppress(OSError):
329+
# Remove the file/directory if it exists e.g. as a leftover from unclean shutdown
330+
# Note: Can be a directory if Docker auto-started container with restart policy
331+
# before Supervisor could write the CID file
332+
with suppress(OSError):
333+
if cidfile_path.is_dir():
334+
cidfile_path.rmdir()
335+
elif cidfile_path.is_file():
332336
cidfile_path.unlink(missing_ok=True)
333337

338+
# Create empty CID file before adding it to volumes to prevent Docker
339+
# from creating it as a directory if container auto-starts
340+
cidfile_path.touch()
341+
334342
extern_cidfile_path = (
335343
self.coresys.config.path_extern_cid_files / f"{name}.cid"
336344
)

supervisor/homeassistant/api.py

Lines changed: 63 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import asyncio
44
from collections.abc import AsyncIterator
5-
from contextlib import asynccontextmanager, suppress
5+
from contextlib import asynccontextmanager
66
from dataclasses import dataclass
77
from datetime import UTC, datetime, timedelta
88
import logging
@@ -15,9 +15,7 @@
1515

1616
from ..coresys import CoreSys, CoreSysAttributes
1717
from ..exceptions import HomeAssistantAPIError, HomeAssistantAuthError
18-
from ..jobs.const import JobConcurrency
19-
from ..jobs.decorator import Job
20-
from ..utils import check_port, version_is_new_enough
18+
from ..utils import version_is_new_enough
2119
from .const import LANDINGPAGE
2220

2321
_LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -43,22 +41,35 @@ def __init__(self, coresys: CoreSys):
4341
# We don't persist access tokens. Instead we fetch new ones when needed
4442
self.access_token: str | None = None
4543
self._access_token_expires: datetime | None = None
44+
self._token_lock: asyncio.Lock = asyncio.Lock()
4645

47-
@Job(
48-
name="home_assistant_api_ensure_access_token",
49-
internal=True,
50-
concurrency=JobConcurrency.QUEUE,
51-
)
5246
async def ensure_access_token(self) -> None:
53-
"""Ensure there is an access token."""
47+
"""Ensure there is a valid access token.
48+
49+
Raises:
50+
HomeAssistantAuthError: When we cannot get a valid token
51+
aiohttp.ClientError: On network or connection errors
52+
TimeoutError: On request timeouts
53+
54+
"""
55+
# Fast path check without lock (avoid unnecessary locking
56+
# for the majority of calls).
5457
if (
5558
self.access_token
5659
and self._access_token_expires
5760
and self._access_token_expires > datetime.now(tz=UTC)
5861
):
5962
return
6063

61-
with suppress(asyncio.TimeoutError, aiohttp.ClientError):
64+
async with self._token_lock:
65+
# Double-check after acquiring lock (avoid race condition)
66+
if (
67+
self.access_token
68+
and self._access_token_expires
69+
and self._access_token_expires > datetime.now(tz=UTC)
70+
):
71+
return
72+
6273
async with self.sys_websession.post(
6374
f"{self.sys_homeassistant.api_url}/auth/token",
6475
timeout=aiohttp.ClientTimeout(total=30),
@@ -92,7 +103,36 @@ async def make_request(
92103
params: MultiMapping[str] | None = None,
93104
headers: dict[str, str] | None = None,
94105
) -> AsyncIterator[aiohttp.ClientResponse]:
95-
"""Async context manager to make a request with right auth."""
106+
"""Async context manager to make authenticated requests to Home Assistant API.
107+
108+
This context manager handles authentication token management automatically,
109+
including token refresh on 401 responses. It yields the HTTP response
110+
for the caller to handle.
111+
112+
Error Handling:
113+
- HTTP error status codes (4xx, 5xx) are preserved in the response
114+
- Authentication is handled transparently with one retry on 401
115+
- Network/connection failures raise HomeAssistantAPIError
116+
- No logging is performed - callers should handle logging as needed
117+
118+
Args:
119+
method: HTTP method (get, post, etc.)
120+
path: API path relative to Home Assistant base URL
121+
json: JSON data to send in request body
122+
content_type: Override content-type header
123+
data: Raw data to send in request body
124+
timeout: Request timeout in seconds
125+
params: URL query parameters
126+
headers: Additional HTTP headers
127+
128+
Yields:
129+
aiohttp.ClientResponse: The HTTP response object
130+
131+
Raises:
132+
HomeAssistantAPIError: When request cannot be completed due to
133+
network errors, timeouts, or connection failures
134+
135+
"""
96136
url = f"{self.sys_homeassistant.api_url}/{path}"
97137
headers = headers or {}
98138

@@ -101,10 +141,9 @@ async def make_request(
101141
headers[hdrs.CONTENT_TYPE] = content_type
102142

103143
for _ in (1, 2):
104-
await self.ensure_access_token()
105-
headers[hdrs.AUTHORIZATION] = f"Bearer {self.access_token}"
106-
107144
try:
145+
await self.ensure_access_token()
146+
headers[hdrs.AUTHORIZATION] = f"Bearer {self.access_token}"
108147
async with getattr(self.sys_websession, method)(
109148
url,
110149
data=data,
@@ -120,23 +159,19 @@ async def make_request(
120159
continue
121160
yield resp
122161
return
123-
except TimeoutError:
124-
_LOGGER.error("Timeout on call %s.", url)
125-
break
162+
except TimeoutError as err:
163+
_LOGGER.debug("Timeout on call %s.", url)
164+
raise HomeAssistantAPIError(str(err)) from err
126165
except aiohttp.ClientError as err:
127-
_LOGGER.error("Error on call %s: %s", url, err)
128-
break
129-
130-
raise HomeAssistantAPIError()
166+
_LOGGER.debug("Error on call %s: %s", url, err)
167+
raise HomeAssistantAPIError(str(err)) from err
131168

132169
async def _get_json(self, path: str) -> dict[str, Any]:
133170
"""Return Home Assistant get API."""
134171
async with self.make_request("get", path) as resp:
135172
if resp.status in (200, 201):
136173
return await resp.json()
137-
else:
138-
_LOGGER.debug("Home Assistant API return: %d", resp.status)
139-
raise HomeAssistantAPIError()
174+
raise HomeAssistantAPIError(f"Home Assistant Core API return {resp.status}")
140175

141176
async def get_config(self) -> dict[str, Any]:
142177
"""Return Home Assistant config."""
@@ -155,15 +190,8 @@ async def get_api_state(self) -> APIState | None:
155190
):
156191
return None
157192

158-
# Check if port is up
159-
if not await check_port(
160-
self.sys_homeassistant.ip_address,
161-
self.sys_homeassistant.api_port,
162-
):
163-
return None
164-
165193
# Check if API is up
166-
with suppress(HomeAssistantAPIError):
194+
try:
167195
# get_core_state is available since 2023.8.0 and preferred
168196
# since it is significantly faster than get_config because
169197
# it does not require serializing the entire config
@@ -181,6 +209,8 @@ async def get_api_state(self) -> APIState | None:
181209
migrating = recorder_state.get("migration_in_progress", False)
182210
live_migration = recorder_state.get("migration_is_live", False)
183211
return APIState(state, migrating and not live_migration)
212+
except HomeAssistantAPIError as err:
213+
_LOGGER.debug("Can't connect to Home Assistant API: %s", err)
184214

185215
return None
186216

supervisor/homeassistant/websocket.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import asyncio
6+
from contextlib import suppress
67
import logging
78
from typing import Any, TypeVar, cast
89

@@ -202,7 +203,8 @@ async def _get_ws_client(self) -> WSClient:
202203
if self._client is not None and self._client.connected:
203204
return self._client
204205

205-
await self.sys_homeassistant.api.ensure_access_token()
206+
with suppress(asyncio.TimeoutError, aiohttp.ClientError):
207+
await self.sys_homeassistant.api.ensure_access_token()
206208
client = await WSClient.connect_with_auth(
207209
self.sys_websession,
208210
self.sys_loop,

0 commit comments

Comments
 (0)