Files
HomeAssistantVS/custom_components/ha_mcp_tools/hacs_nudge.py
T
2026-07-20 22:52:35 -04:00

175 lines
8.0 KiB
Python

"""Nudge HACS to refresh this component's repository info when a newer
component is detected (#1783/#1785 follow-up).
The component notices a newer custom component quickly — it checks PyPI every
6 hours (and on every reload/restart) and the auto-update gate reads the
component version shipped at each server release's tag. HACS, by contrast,
refreshes a *custom* repository's release data only about every 48 hours, so
the component update the hold (or the component-outdated repair) is waiting on
is usually not yet visible in HACS. This module runs the same force-refresh
that HACS's own repository "Update information" menu action performs, so HACS's
update entity flips promptly and Home Assistant advertises the component update
natively instead of the user waiting out HACS's cache.
HACS registers no service and ``homeassistant.update_entity`` is a no-op on its
entities, so there is no supported API for this: the refresh reaches directly
into HACS internals (``hass.data["hacs"]``). Those internals can change under us
at any HACS release, so EVERY access here is defensive and the whole interaction
is advisory — any failure degrades to a debug log and never touches the caller's
update-check path (which runs on bring-up, gating webhook registration, and on
the version coordinator's listener). The same unsupported ``hass.data["hacs"]``
reach as install_source_check, but deliberately logged at debug where that
module warns: this path retries on every 6h check, so a persistent HACS shape
change would otherwise warn forever about an advisory nicety.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from .const import (
DOMAIN,
HACS_LEGACY_REPO_FULL_NAME,
HACS_MIRROR_REPO_FULL_NAME,
)
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
_LOGGER = logging.getLogger(__name__)
# hass.data[DOMAIN] sub-key holding the SET of component versions HACS was
# already asked to refresh for. The hold check runs every 6h; without this the
# same pending version would force a fresh HACS network refresh on every pass.
# A set, not a single string: the update hold nudges with the shipped version
# while the component-outdated check nudges with the required version, and a
# scalar marker would ping-pong between the two, re-refreshing every pass
# (review finding). Distinct from the other DOMAIN sub-keys (const.py) so both
# entry types can share hass.data[DOMAIN].
_DATA_HACS_NUDGED_VERSIONS = "hacs_nudged_versions"
# The repository full_names (owner/repo) HACS may track this component under, in
# lookup order: the dedicated mirror first (the current install path), the
# legacy main-repo path second (pre-mirror installs — see install_source_check).
_CANDIDATE_REPO_FULL_NAMES = (
HACS_MIRROR_REPO_FULL_NAME,
HACS_LEGACY_REPO_FULL_NAME,
)
def async_schedule_hacs_nudge(hass: HomeAssistant, target_version: str) -> None:
"""Fire-and-forget the HACS refresh nudge for ``target_version``.
Scheduled as a background task rather than awaited: ``update_repository``
makes a GitHub network call, and the callers must not block on it — the
component-compat check is awaited inside the server bring-up *before*
webhook registration (blocking it would delay the connect URLs for a
display-only refresh), and the auto-update hold runs on the version
coordinator's listener. ``async_create_task`` keeps a strong reference so
the task is not garbage-collected mid-flight; every failure is contained
inside :func:`async_nudge_hacs_refresh`.
"""
hass.async_create_task(
async_nudge_hacs_refresh(hass, target_version),
f"{DOMAIN}_hacs_nudge",
)
async def async_nudge_hacs_refresh(hass: HomeAssistant, target_version: str) -> None:
"""Ask HACS to re-fetch this component's repository info for ``target_version``.
Throttled to at most one refresh per detected component version (the marker
lives in ``hass.data[DOMAIN]``): the hold check repeats every 6h, and
hammering HACS's GitHub fetch each pass for the same pending version would
be pointless. Absent / broken HACS and a not-yet-registered repository stay
unthrottled so a later pass (once HACS is ready) still gets its one refresh.
"""
domain_data = hass.data.setdefault(DOMAIN, {})
nudged_versions: set[str] = domain_data.setdefault(
_DATA_HACS_NUDGED_VERSIONS, set()
)
if target_version in nudged_versions:
# Already refreshed HACS for this pending component version.
return
try:
refreshed = await _async_force_hacs_repo_refresh(hass)
except Exception:
# HACS internals are unsupported and may change shape (missing hacs,
# renamed attributes, a network failure inside update_repository); any
# of it must degrade to a debug log, never fault the caller.
_LOGGER.debug(
"HA-MCP: could not nudge HACS to refresh the component repository",
exc_info=True,
)
return
if refreshed:
# Throttle only on a completed refresh, so a transient miss (HACS not
# set up yet, repo not registered this pass) is retried next check
# rather than suppressed for this version forever.
nudged_versions.add(target_version)
async def _async_force_hacs_repo_refresh(hass: HomeAssistant) -> bool:
"""Run HACS's "Update information" force-refresh for this component's repo.
Returns True when a tracked repository was found and its refresh completed,
False when there is nothing to refresh (no HACS, or no INSTALLED repository
under either candidate name). Reaches into HACS internals —
the top-level lookups are ``getattr``-guarded so a wholly different HACS
shape returns False cleanly; anything deeper that changes shape raises and is
swallowed by :func:`async_nudge_hacs_refresh`.
"""
hacs = hass.data.get("hacs")
if hacs is None:
# No HACS (manual/copy install, or HACS set up later this run) — nothing
# to refresh; the caller leaves the throttle unset so a later pass retries.
return False
repositories = getattr(hacs, "repositories", None)
get_by_full_name = getattr(repositories, "get_by_full_name", None)
if get_by_full_name is None:
return False
repository = None
for full_name in _CANDIDATE_REPO_FULL_NAMES:
candidate = get_by_full_name(full_name)
if candidate is None:
continue
# HACS keeps a repository record for every ADDED repo, downloaded or
# not, but only creates an update entity for DOWNLOADED ones — and a
# legacy->mirror migration can leave the mirror added but not yet
# (re)installed while the running component is still tracked under the
# legacy record. Refreshing an uninstalled record lights up nothing, so
# only an installed candidate counts (review finding).
if not getattr(getattr(candidate, "data", None), "installed", False):
continue
repository = candidate
break
if repository is None:
return False
# The repository's "Update information" menu action: re-fetch its release
# data ignoring cached state, then push the fresh data to HACS's own update
# entity so Home Assistant advertises the component update immediately.
await repository.update_repository(ignore_issues=True, force=True)
# The refresh is complete at this point; the listener push below only
# re-publishes the fresh data to HACS's update entity sooner. Guarded
# separately so a HACS shape change here cannot void the completed
# refresh's throttle and re-run the network fetch every pass (review
# finding).
try:
coordinators = getattr(hacs, "coordinators", None) or {}
category = getattr(getattr(repository, "data", None), "category", None)
coordinator = coordinators.get(category)
if coordinator is not None:
coordinator.async_update_listeners()
except Exception:
_LOGGER.debug(
"HA-MCP: HACS listener push after the repository refresh failed",
exc_info=True,
)
return True