Skip to content

Commit a109160

Browse files
feat: Automatically surface component updates in HACS after a server update (#2166)
* feat: Automatically surface component updates in HACS after a server update HACS refreshes a custom repository's release data only about every 48 hours, so the component update paired with each server release stayed invisible unless the user ran the HACS UI's "Update information" by hand. The existing component-side nudge (hacs_nudge.py) only covers the embedded server. Server side: on startup, when the server version changed since the last recorded nudge or a newer release is pending, send hacs/repository/refresh for every installed candidate repository entry (dedicated mirror and legacy main-repo path), over the existing admin WebSocket. Advisory only: failures degrade to debug logs, a marker file in the data dir throttles repeat passes, embedded installs are skipped (hacs_nudge covers them), and HA_MCP_DISABLE_UPDATE_CHECK opts out. Component side: the nudge now refreshes ALL installed candidate repositories instead of the first match, so a legacy-path install is refreshed even when the mirror entry is also present. * fix: Address review findings on the startup HACS nudge - An all-candidates-failed refresh pass now reports undetermined instead of complete, so the retry schedule and next startup still run. - unknown_command is retried through the whole schedule before HACS is recorded absent: a still-booting HA returns the same reply before HACS registers its WebSocket handlers. - HomeAssistantCommandTimeout joins the retry classification (it is a sibling of HomeAssistantCommandError, not a subclass). - Per-user OAuth mode is skipped: it holds no server-level HA credential for the nudge to use. - The marker records the HA URL, so servers sharing a data dir but targeting different instances cannot suppress each other's nudge. - Drift-guard test pins CANDIDATE_REPO_FULL_NAMES to the component's constants. * fix: Per-target refresh markers and websocket-closure retries - One marker file per Home Assistant target (URL-hashed filename): server configs sharing a data dir but pointing at different instances no longer invalidate each other's marker on alternating spawns. The scalar ha_url marker field and its _nudge_due check are gone — the scoping lives in the path. - Raw websocket exceptions join the retry classification: send_command deliberately re-raises the original exception on a mid-send closure, which previously escaped the retry loop to the outer advisory catch. * docs(internal): Correct the both-installed comment on the refresh loops Two candidate entries read installed only when the mirror was downloaded without removing the legacy record — HACS blocks adding the same repository twice but has no same-domain guard. A legacy-to-mirror migration leaves the mirror added but not downloaded, which is one installed candidate, so calling that state 'mid migration' was wrong on both loops. --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com>
1 parent 37acd1f commit a109160

8 files changed

Lines changed: 810 additions & 28 deletions

File tree

custom_components/ha_mcp_tools/hacs_nudge.py

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from __future__ import annotations
2727

2828
import logging
29-
from typing import TYPE_CHECKING
29+
from typing import TYPE_CHECKING, Any
3030

3131
from .const import (
3232
DOMAIN,
@@ -113,14 +113,15 @@ async def async_nudge_hacs_refresh(hass: HomeAssistant, target_version: str) ->
113113

114114

115115
async def _async_force_hacs_repo_refresh(hass: HomeAssistant) -> bool:
116-
"""Run HACS's "Update information" force-refresh for this component's repo.
117-
118-
Returns True when a tracked repository was found and its refresh completed,
119-
False when there is nothing to refresh (no HACS, or no INSTALLED repository
120-
under either candidate name). Reaches into HACS internals —
121-
the top-level lookups are ``getattr``-guarded so a wholly different HACS
122-
shape returns False cleanly; anything deeper that changes shape raises and is
123-
swallowed by :func:`async_nudge_hacs_refresh`.
116+
"""Run HACS's "Update information" force-refresh for this component's repos.
117+
118+
Returns True when at least one INSTALLED tracked repository completed its
119+
refresh, False when there was nothing to refresh (no HACS, or no installed
120+
repository under either candidate name) or every candidate's refresh
121+
failed. Reaches into HACS internals — the top-level lookups are
122+
``getattr``-guarded so a wholly different HACS shape returns False cleanly;
123+
anything deeper that changes shape raises and is swallowed by
124+
:func:`async_nudge_hacs_refresh`.
124125
"""
125126
hacs = hass.data.get("hacs")
126127
if hacs is None:
@@ -133,7 +134,7 @@ async def _async_force_hacs_repo_refresh(hass: HomeAssistant) -> bool:
133134
if get_by_full_name is None:
134135
return False
135136

136-
repository = None
137+
installed_candidates: list[Any] = []
137138
for full_name in _CANDIDATE_REPO_FULL_NAMES:
138139
candidate = get_by_full_name(full_name)
139140
if candidate is None:
@@ -146,20 +147,41 @@ async def _async_force_hacs_repo_refresh(hass: HomeAssistant) -> bool:
146147
# only an installed candidate counts (review finding).
147148
if not getattr(getattr(candidate, "data", None), "installed", False):
148149
continue
149-
repository = candidate
150-
break
151-
if repository is None:
150+
installed_candidates.append(candidate)
151+
if not installed_candidates:
152152
return False
153153

154-
# The repository's "Update information" menu action: re-fetch its release
155-
# data ignoring cached state, then push the fresh data to HACS's own update
156-
# entity so Home Assistant advertises the component update immediately.
157-
await repository.update_repository(ignore_issues=True, force=True)
158-
# The refresh is complete at this point; the listener push below only
159-
# re-publishes the fresh data to HACS's update entity sooner. Guarded
160-
# separately so a HACS shape change here cannot void the completed
161-
# refresh's throttle and re-run the network fetch every pass (review
162-
# finding).
154+
# Every installed candidate, not just the first. Two entries read
155+
# installed when the mirror was downloaded without removing the legacy
156+
# record — HACS blocks adding the same repository twice but not two
157+
# repositories sharing a domain — and each carries its own update entity,
158+
# so refreshing only the first left the other stale.
159+
refreshed_any = False
160+
for repository in installed_candidates:
161+
# The repository's "Update information" menu action: re-fetch its
162+
# release data ignoring cached state.
163+
try:
164+
await repository.update_repository(ignore_issues=True, force=True)
165+
except Exception:
166+
# One candidate's failure must not skip the other's refresh.
167+
_LOGGER.debug(
168+
"HA-MCP: HACS refresh failed for one candidate repository",
169+
exc_info=True,
170+
)
171+
continue
172+
refreshed_any = True
173+
_poke_hacs_update_entity(hacs, repository)
174+
return refreshed_any
175+
176+
177+
def _poke_hacs_update_entity(hacs: Any, repository: Any) -> None:
178+
"""Push the freshly fetched data to HACS's own update entity.
179+
180+
The refresh is already complete when this runs; the push only makes Home
181+
Assistant advertise the component update sooner. Guarded separately so a
182+
HACS shape change here cannot void the completed refresh's throttle and
183+
re-run the network fetch every pass (review finding).
184+
"""
163185
try:
164186
coordinators = getattr(hacs, "coordinators", None) or {}
165187
category = getattr(getattr(repository, "data", None), "category", None)
@@ -171,4 +193,3 @@ async def _async_force_hacs_repo_refresh(hass: HomeAssistant) -> bool:
171193
"HA-MCP: HACS listener push after the repository refresh failed",
172194
exc_info=True,
173195
)
174-
return True

src/ha_mcp/__main__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,13 @@ async def _run_with_shutdown(server_coro: Coroutine[Any, Any, Any]) -> None:
666666
server_task = asyncio.create_task(server_coro)
667667
shutdown_task = asyncio.create_task(_shutdown_event.wait())
668668

669+
# Fire-and-forget: ask HACS to surface a paired component update after a
670+
# server update (advisory; see hacs_auto_refresh). Not in the wait set —
671+
# its completion must not stop the server.
672+
from ha_mcp.hacs_auto_refresh import maybe_refresh_hacs_after_update
673+
674+
hacs_refresh_task = asyncio.create_task(maybe_refresh_hacs_after_update())
675+
669676
try:
670677
done, pending = await asyncio.wait(
671678
[server_task, shutdown_task],
@@ -725,7 +732,7 @@ async def _run_with_shutdown(server_coro: Coroutine[Any, Any, Any]) -> None:
725732
logger.warning("Resource cleanup timed out")
726733

727734
try:
728-
await _cancel_tasks(server_task, shutdown_task)
735+
await _cancel_tasks(server_task, shutdown_task, hacs_refresh_task)
729736
except Exception as e:
730737
# Teardown must never mask the exception being propagated from the
731738
# try block (Python drops the original if finally raises).

src/ha_mcp/hacs_auto_refresh.py

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
"""Ask HACS to surface the paired component update after a server update.
2+
3+
HACS refreshes a *custom* repository's release data from GitHub only about
4+
every 48 hours, and the ha_mcp_tools component ships in lockstep with each
5+
server release — so a freshly updated server can sit for two days next to a
6+
component update HACS has not noticed yet. The component's own nudge
7+
(``custom_components/ha_mcp_tools/hacs_nudge.py``) closes that gap only for the
8+
embedded server, which is where it runs; add-on, Docker, pip and stdio installs
9+
had no trigger at all. This module is that trigger, server-side.
10+
11+
On startup, when the server version changed since the last recorded nudge (the
12+
just-updated case) or a newer release is pending, it sends HACS's "Update
13+
information" refresh (``hacs/repository/refresh``) over the existing admin
14+
WebSocket for every INSTALLED candidate repository entry.
15+
16+
Fully advisory: every failure degrades to a debug log and nothing here can
17+
fault startup. A marker file in the data dir records the state the last
18+
completed pass ran against, so the common case — a per-conversation stdio
19+
spawn on an unchanged version — costs one file read and no WebSocket traffic.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import asyncio
25+
import hashlib
26+
import json
27+
import logging
28+
from pathlib import Path
29+
from typing import Any
30+
31+
from ._vendor import websockets
32+
from ._version import get_version, is_embedded
33+
from .client.rest_client import (
34+
HomeAssistantCommandError,
35+
HomeAssistantCommandTimeout,
36+
HomeAssistantConnectionError,
37+
)
38+
from .config import OAUTH_MODE_TOKEN, get_global_settings
39+
from .update_check import UpdateInfo, get_update_info, is_update_check_disabled
40+
from .utils.data_paths import get_data_dir
41+
42+
logger = logging.getLogger(__name__)
43+
44+
# The repository full_names HACS may track the ha_mcp_tools component under —
45+
# the dedicated mirror first, the legacy main-repo path second. Values are
46+
# duplicated from custom_components/ha_mcp_tools/const.py (HACS_MIRROR_ /
47+
# HACS_LEGACY_REPO_FULL_NAME): the server package cannot import the component.
48+
CANDIDATE_REPO_FULL_NAMES = (
49+
"homeassistant-ai/ha-mcp-integration",
50+
"homeassistant-ai/ha-mcp",
51+
)
52+
53+
# Marker file (in the ha-mcp data dir) recording the state the last completed
54+
# nudge ran against. Written only on a completed pass, so a failed pass (HA
55+
# still booting, HACS not loaded yet) is retried on the next startup. One file
56+
# PER Home Assistant target: server configs sharing a data dir but pointing at
57+
# different instances must neither suppress each other's nudge nor invalidate
58+
# each other's marker on every alternating spawn (review finding).
59+
MARKER_FILENAME_PREFIX = "hacs_refresh_marker"
60+
61+
# The add-on can start before HA Core finishes booting (and before HACS is
62+
# loaded), so the first attempts may fail; spread retries over ~8 minutes,
63+
# then give up until the next startup (the unwritten marker retries then).
64+
RETRY_DELAYS = (30.0, 60.0, 120.0, 300.0)
65+
66+
67+
def _marker_path(ha_url: str) -> Path:
68+
digest = hashlib.sha256(ha_url.rstrip("/").encode()).hexdigest()[:12]
69+
return get_data_dir() / f"{MARKER_FILENAME_PREFIX}_{digest}.json"
70+
71+
72+
def _read_marker(ha_url: str) -> dict[str, Any] | None:
73+
"""Return the last completed pass's marker, or None when there isn't one."""
74+
path = _marker_path(ha_url)
75+
try:
76+
payload = json.loads(path.read_text(encoding="utf-8"))
77+
except FileNotFoundError:
78+
return None
79+
except (OSError, ValueError) as err:
80+
logger.debug("Ignoring unreadable HACS refresh marker %s: %s", path, err)
81+
return None
82+
return payload if isinstance(payload, dict) else None
83+
84+
85+
def _write_marker(ha_url: str, marker: dict[str, Any]) -> None:
86+
"""Record the state this pass ran against; an unwritable dir just retries."""
87+
path = _marker_path(ha_url)
88+
try:
89+
path.write_text(json.dumps(marker), encoding="utf-8")
90+
except OSError as err:
91+
logger.debug("Could not write HACS refresh marker %s: %s", path, err)
92+
93+
94+
def _nudge_due(
95+
current_version: str,
96+
info: UpdateInfo | None,
97+
marker: dict[str, Any] | None,
98+
) -> bool:
99+
"""Decide whether this startup should ask HACS for a refresh.
100+
101+
Instance scoping lives in the marker PATH (one file per HA target), so a
102+
marker handed in here always describes the current instance.
103+
"""
104+
if marker is None:
105+
# First run ever, or no pass has ever completed for this HA target.
106+
return True
107+
if marker.get("server_version") != current_version:
108+
# The server was just updated, and the paired component release is
109+
# exactly what HACS needs to surface.
110+
return True
111+
# A release appeared while this build sat idle — surface its component side.
112+
return bool(
113+
info is not None
114+
and info.update_available
115+
and marker.get("latest") != info.latest
116+
)
117+
118+
119+
async def _refresh_installed_candidates() -> dict[str, Any] | None:
120+
"""Refresh every installed candidate repository; one attempt, no retries.
121+
122+
Returns the pass result (``hacs`` presence plus the full_names refreshed),
123+
or None when the outcome could not be determined.
124+
"""
125+
from .client.websocket_client import get_websocket_client
126+
from .tools.hacs_registration import send_hacs_repository_refresh
127+
128+
ws_client = await get_websocket_client()
129+
try:
130+
response = await ws_client.send_command("hacs/repositories/list")
131+
except HomeAssistantCommandError as err:
132+
# HACS not installed at all — HA rejects its commands as unknown. Same
133+
# detection as ``_assert_hacs_available`` in tools/tools_hacs.py. Any
134+
# other command failure is a real error: let the retry loop see it.
135+
if err.code == "unknown_command" or "unknown command" in str(err).lower():
136+
return {"hacs": "absent", "refreshed": []}
137+
raise
138+
139+
wanted = {name.lower() for name in CANDIDATE_REPO_FULL_NAMES}
140+
refreshed: list[str] = []
141+
attempted = 0
142+
for repo in response.get("result", []):
143+
full_name = (repo.get("full_name") or "").lower()
144+
# HACS keeps a record for every ADDED repo but only creates an update
145+
# entity for downloaded ones, so refreshing an uninstalled record
146+
# lights up nothing.
147+
if full_name not in wanted or not repo.get("installed"):
148+
continue
149+
attempted += 1
150+
try:
151+
await send_hacs_repository_refresh(ws_client, str(repo["id"]))
152+
except Exception as err:
153+
# Both entries read installed only when someone downloaded the
154+
# mirror without removing the legacy record — HACS guards against
155+
# adding the same repository twice but not against two
156+
# repositories sharing a domain. Rare, but one failing must not
157+
# cost the other its refresh.
158+
logger.debug("HACS refresh failed for %s: %s", full_name, err)
159+
continue
160+
refreshed.append(full_name)
161+
if attempted and not refreshed:
162+
# Every attempted refresh failed — the pass is undetermined, not
163+
# complete. Returning a result here would write the marker and cancel
164+
# the retry schedule and the next startup's pass (review finding).
165+
return None
166+
return {"hacs": "present", "refreshed": refreshed}
167+
168+
169+
async def _refresh_with_retries() -> dict[str, Any] | None:
170+
"""Run the refresh pass, retrying transport failures over ``RETRY_DELAYS``."""
171+
absent_result: dict[str, Any] | None = None
172+
for delay in (0.0, *RETRY_DELAYS):
173+
if delay:
174+
await asyncio.sleep(delay)
175+
try:
176+
result = await _refresh_installed_candidates()
177+
except (
178+
HomeAssistantConnectionError,
179+
HomeAssistantCommandError,
180+
HomeAssistantCommandTimeout,
181+
# send_command deliberately re-raises the ORIGINAL exception when
182+
# the socket closes mid-send (ambiguous-write contract), so raw
183+
# websocket closures reach this loop unwrapped (review finding).
184+
websockets.exceptions.WebSocketException,
185+
OSError,
186+
) as err:
187+
logger.debug("HACS repository refresh attempt failed: %s", err)
188+
continue
189+
if result is None:
190+
continue
191+
if result["hacs"] == "absent":
192+
# ``unknown_command`` also happens while HA is still booting,
193+
# before HACS registers its WS handlers — indistinguishable from
194+
# no HACS at all. Keep retrying; record absence only once the
195+
# schedule is exhausted (review finding).
196+
absent_result = result
197+
continue
198+
return result
199+
return absent_result
200+
201+
202+
async def maybe_refresh_hacs_after_update() -> None:
203+
"""Nudge HACS once per startup when the update picture changed."""
204+
try:
205+
if is_embedded():
206+
# The component's own hacs_nudge already covers embedded installs;
207+
# running both would double HACS's GitHub fetches.
208+
return
209+
if is_update_check_disabled():
210+
return
211+
212+
settings = get_global_settings()
213+
if settings.homeassistant_token == OAUTH_MODE_TOKEN:
214+
# Per-user OAuth mode holds no server-level HA credential — the
215+
# nudge would only burn its retry schedule on auth failures.
216+
return
217+
ha_url = settings.homeassistant_url
218+
219+
current = get_version()
220+
# Normally a cache hit — the startup banner warms the lru_cache — but
221+
# offloaded anyway so a cold call can never touch the event loop.
222+
info = await asyncio.to_thread(get_update_info)
223+
marker = await asyncio.to_thread(_read_marker, ha_url)
224+
if not _nudge_due(current, info, marker):
225+
return
226+
227+
result = await _refresh_with_retries()
228+
if result is None:
229+
logger.debug(
230+
"Could not reach HACS to refresh the component repository; "
231+
"giving up until next startup"
232+
)
233+
return
234+
235+
await asyncio.to_thread(
236+
_write_marker,
237+
ha_url,
238+
{
239+
"server_version": current,
240+
"latest": info.latest if info else None,
241+
"hacs": result["hacs"],
242+
},
243+
)
244+
if result["refreshed"]:
245+
logger.info(
246+
"Asked HACS to refresh component repository info: %s",
247+
", ".join(result["refreshed"]),
248+
)
249+
except asyncio.CancelledError:
250+
raise
251+
except Exception:
252+
logger.debug("HACS auto-refresh nudge skipped", exc_info=True)

src/ha_mcp/update_check.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,16 @@ def _is_disabled() -> bool:
7979
return os.environ.get(DISABLE_ENV, "").strip().lower() in _TRUTHY
8080

8181

82+
def is_update_check_disabled() -> bool:
83+
"""Public accessor for the HA_MCP_DISABLE_UPDATE_CHECK opt-out.
84+
85+
The HACS auto-refresh nudge gates on the same switch: an operator who
86+
opted out of update phone-home should not get update-driven side
87+
effects either.
88+
"""
89+
return _is_disabled()
90+
91+
8292
def _is_newer(latest: str, current: str) -> bool:
8393
"""Return True only when ``latest`` is a strictly higher PEP 440 release.
8494

0 commit comments

Comments
 (0)