714 lines
30 KiB
Python
714 lines
30 KiB
Python
"""Bring the in-process ha-mcp server up and down for the config entry (#1527).
|
|
|
|
Orchestration between :mod:`embedded_server` (the server thread + token
|
|
provisioning) and :mod:`mcp_webhook` (the ingress webhook): the bring-up sequence,
|
|
repair issues on failure, connect-URL surfacing, and teardown. Kept out of
|
|
``__init__.py`` so the entry-point wiring stays thin and this logic is
|
|
independently testable.
|
|
|
|
Every failure here is contained: a failure files a repair issue and returns
|
|
rather than propagating out of the background bring-up task, so the rest of Home
|
|
Assistant keeps running even when the server can't be installed or started.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from contextlib import suppress
|
|
from typing import TYPE_CHECKING
|
|
from urllib.parse import urlparse
|
|
|
|
from aiohttp import ClientError
|
|
from awesomeversion import AwesomeVersion, AwesomeVersionException
|
|
from homeassistant.components import persistent_notification
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers import issue_registry as ir
|
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
from homeassistant.loader import async_get_integration
|
|
|
|
from .const import (
|
|
BIND_HOST_ALL,
|
|
CHANNEL_DEV,
|
|
COMPONENT_MANIFEST_AT_TAG_URL,
|
|
DATA_BRINGUP_TASK,
|
|
DATA_MANAGER,
|
|
DATA_PENDING_UPDATE_NOTIFY,
|
|
DATA_SECRET_PATH,
|
|
DATA_UPDATE_COORDINATOR,
|
|
DATA_WEBHOOK_ID,
|
|
DEFAULT_AUTO_UPDATE,
|
|
DEFAULT_BIND_HOST,
|
|
DEFAULT_ENABLE_LLM_API,
|
|
DEFAULT_PIP_SPEC,
|
|
DEFAULT_SERVER_PORT,
|
|
DOMAIN,
|
|
HACS_COMPONENT_URL,
|
|
ISSUE_COMPONENT_OUTDATED,
|
|
ISSUE_PACKAGE_FAILED,
|
|
ISSUE_START_FAILED,
|
|
ISSUE_UPDATE_HELD,
|
|
OPT_AUTO_UPDATE,
|
|
OPT_BIND_HOST,
|
|
OPT_ENABLE_LLM_API,
|
|
OPT_ENABLE_SIDEBAR_PANEL,
|
|
OPT_ENABLE_STARTUP_NOTIFICATION,
|
|
OPT_ENABLE_WEBHOOK,
|
|
OPT_EXTERNAL_URL,
|
|
OPT_PIP_SPEC,
|
|
OPT_SERVER_PORT,
|
|
OPT_WEBHOOK_AUTH,
|
|
WEBHOOK_AUTH_NONE,
|
|
channel_for_dist,
|
|
)
|
|
from .embedded_server import EmbeddedServerError, EmbeddedServerManager
|
|
from .llm_api import async_register_llm_api, async_unregister_llm_api
|
|
from .mcp_webhook import async_register_webhook, async_unregister_webhook
|
|
|
|
if TYPE_CHECKING:
|
|
from homeassistant.config_entries import ConfigEntry
|
|
|
|
from .coordinator import ServerVersionInfo
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
_NOTIFICATION_ID = "ha_mcp_tools_server_connect"
|
|
_UPDATE_NOTIFICATION_ID = "ha_mcp_tools_server_updated"
|
|
# ISSUE_UPDATE_HELD is cleared at bring-up start too: any reload that reaches
|
|
# bring-up either bypassed the hold deliberately (the update entity's Install
|
|
# button) or made it moot; if the hold still applies, the coordinator refresh
|
|
# that follows setup re-files it within moments.
|
|
_ISSUE_IDS = (ISSUE_PACKAGE_FAILED, ISSUE_START_FAILED, ISSUE_UPDATE_HELD)
|
|
|
|
# Per-request timeout for the component-manifest fetch behind the auto-update
|
|
# gate — mirrors the coordinator's PyPI fetch budget; a miss fails open.
|
|
_MANIFEST_FETCH_TIMEOUT_SECONDS = 30
|
|
|
|
|
|
async def async_bring_up_server(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
"""Install, start, and expose the server. Runs as a background task.
|
|
|
|
On failure files the matching repair issue and returns — Home Assistant stays
|
|
up. On cancellation (the entry is being unloaded mid-bring-up) tears down any
|
|
partial state and re-raises so the task ends cancelled. The secret webhook id
|
|
and secret path must already exist in ``entry.data`` (the entry setup writes
|
|
them before scheduling this task).
|
|
"""
|
|
_clear_issues(hass)
|
|
|
|
manager = EmbeddedServerManager(hass, entry)
|
|
hass.data.setdefault(DOMAIN, {})[DATA_MANAGER] = manager
|
|
|
|
try:
|
|
await manager.async_start()
|
|
|
|
# The package is installed and importable now: verify the running
|
|
# component satisfies the server's MIN_COMPONENT_VERSION and file/clear
|
|
# the component-outdated repair issue. Advisory only — it never blocks
|
|
# the (already started) server.
|
|
await _async_check_component_compat(hass, entry)
|
|
|
|
auth_mode = str(entry.options.get(OPT_WEBHOOK_AUTH, WEBHOOK_AUTH_NONE))
|
|
secret_path = str(entry.data[DATA_SECRET_PATH])
|
|
webhook_enabled = bool(entry.options.get(OPT_ENABLE_WEBHOOK, True))
|
|
# Always set up the loopback forwarding config — the sidebar settings
|
|
# panel proxies through it (#1803); the option gates only the public
|
|
# webhook endpoint.
|
|
await async_register_webhook(
|
|
hass,
|
|
entry,
|
|
port=manager.port,
|
|
secret_path=secret_path,
|
|
auth_mode=auth_mode,
|
|
register_endpoint=webhook_enabled,
|
|
)
|
|
if not webhook_enabled:
|
|
_LOGGER.info(
|
|
"Webhook access disabled by option - the server is local-only "
|
|
"(direct port + sidebar panel)"
|
|
)
|
|
_surface_connect_urls(hass, entry, auth_mode, webhook_enabled=webhook_enabled)
|
|
# Conversation-agent LLM API (#1745), gated on its option (default on).
|
|
# Advisory: registration failures are contained inside (logged, feature
|
|
# absent) — the running server must never be taken down by them.
|
|
if bool(entry.options.get(OPT_ENABLE_LLM_API, DEFAULT_ENABLE_LLM_API)):
|
|
await async_register_llm_api(
|
|
hass, entry, port=manager.port, secret_path=secret_path
|
|
)
|
|
else:
|
|
_LOGGER.info(
|
|
"Conversation-agent LLM API disabled by option - the toolset "
|
|
"will not be offered to Home Assistant conversation agents"
|
|
)
|
|
await _async_finish_update_cycle(hass)
|
|
except asyncio.CancelledError:
|
|
# Unloaded mid-bring-up: undo whatever partial state exists, then let the
|
|
# cancellation propagate so the task ends cancelled. The pending
|
|
# update-notification marker (if any) deliberately survives — it
|
|
# belongs to a bring-up that has not run yet, not to this one.
|
|
await async_teardown_server(hass)
|
|
raise
|
|
except EmbeddedServerError as err:
|
|
_LOGGER.error("HA-MCP in-process server failed to start: %s", err)
|
|
# suppress: filing the repair issue must be UNCONDITIONAL (review
|
|
# finding) - a raising teardown would otherwise leave the entry
|
|
# looking healthy with the failure visible only in the log.
|
|
with suppress(Exception):
|
|
await async_teardown_server(hass)
|
|
_create_issue(hass, err.kind, str(err))
|
|
# The install did not land: never fire the "updated" notification for
|
|
# it — the repair issue above is the user-facing signal.
|
|
_drop_pending_update_notify(hass)
|
|
except Exception as err:
|
|
_LOGGER.exception("HA-MCP in-process server: bring-up failed")
|
|
with suppress(Exception):
|
|
await async_teardown_server(hass)
|
|
_create_issue(hass, "start", str(err))
|
|
_drop_pending_update_notify(hass)
|
|
|
|
|
|
async def async_teardown_server(hass: HomeAssistant) -> None:
|
|
"""Unregister the LLM API + webhook and stop the server thread (reload-safe,
|
|
idempotent).
|
|
|
|
Does NOT revoke the provisioned token — a reload must keep it. The ha_auth
|
|
discovery views stay bound (aiohttp can't unregister them until HA restarts);
|
|
they 404 while the entry is not live.
|
|
"""
|
|
async_unregister_llm_api(hass)
|
|
await async_unregister_webhook(hass)
|
|
manager = hass.data.get(DOMAIN, {}).pop(DATA_MANAGER, None)
|
|
if isinstance(manager, EmbeddedServerManager):
|
|
await manager.async_stop()
|
|
|
|
|
|
async def async_revoke_credentials_on_remove(
|
|
hass: HomeAssistant, entry: ConfigEntry
|
|
) -> None:
|
|
"""Revoke the provisioned credentials when the config entry is removed."""
|
|
await EmbeddedServerManager(hass, entry).async_revoke_credentials()
|
|
_clear_issues(hass)
|
|
ir.async_delete_issue(hass, DOMAIN, ISSUE_COMPONENT_OUTDATED)
|
|
|
|
|
|
def build_connect_urls(
|
|
hass: HomeAssistant,
|
|
entry: ConfigEntry,
|
|
*,
|
|
webhook_enabled: bool = True,
|
|
) -> list[str]:
|
|
"""Resolve the entry's connect URLs (webhook forms first, then direct).
|
|
|
|
Shared by the admin-only surfaces that show real URLs: the Home Assistant
|
|
log on start-up and the entry's Configure screen (the notification
|
|
deliberately carries none - it is visible to every signed-in user). Each
|
|
source is best-effort: a URL that cannot be resolved is omitted.
|
|
"""
|
|
from homeassistant.helpers.network import NoURLAvailableError, get_url
|
|
|
|
webhook_id = entry.data.get(DATA_WEBHOOK_ID)
|
|
urls: list[str] = []
|
|
external = str(entry.options.get(OPT_EXTERNAL_URL) or "").rstrip("/")
|
|
if not webhook_enabled:
|
|
# Local-only mode: no webhook exists, so no webhook URLs to surface.
|
|
external = ""
|
|
webhook_id = None
|
|
if external:
|
|
# Owner-requested parity with the webhook-proxy app: a configured
|
|
# external URL leads the list (any reverse proxy, not just Nabu Casa).
|
|
urls.append(f"{external}/api/webhook/{webhook_id}")
|
|
|
|
# Nabu Casa remote URL (only when the cloud integration is set up + logged in).
|
|
try:
|
|
from homeassistant.components.cloud import (
|
|
CloudNotAvailable,
|
|
async_remote_ui_url,
|
|
)
|
|
|
|
try:
|
|
if webhook_id:
|
|
cloud_base = async_remote_ui_url(hass)
|
|
urls.append(f"{cloud_base}/api/webhook/{webhook_id}")
|
|
except CloudNotAvailable:
|
|
pass # Cloud not logged in / remote UI off - no remote URL to show.
|
|
except ImportError:
|
|
pass # Cloud integration not installed (e.g. HA Core) - local URL only.
|
|
|
|
local_host: str | None = None
|
|
try:
|
|
local_base = get_url(hass, allow_external=False, prefer_external=False)
|
|
local_host = urlparse(local_base).hostname
|
|
if webhook_id:
|
|
urls.append(f"{local_base}/api/webhook/{webhook_id}")
|
|
except NoURLAvailableError:
|
|
pass # No internal/local URL configured - fall through to the hint form.
|
|
|
|
if not urls and webhook_id:
|
|
urls.append(f"/api/webhook/{webhook_id} (prefix with your Home Assistant URL)")
|
|
|
|
port = int(entry.options.get(OPT_SERVER_PORT, DEFAULT_SERVER_PORT))
|
|
bind_host = str(entry.options.get(OPT_BIND_HOST, DEFAULT_BIND_HOST))
|
|
secret_path = entry.data.get(DATA_SECRET_PATH)
|
|
if bind_host == BIND_HOST_ALL and secret_path:
|
|
# Direct-access URL: admin-gated surfaces only (log + Configure screen).
|
|
# Guarded on the secret path so a missing one omits the line instead of
|
|
# rendering a valid-looking URL without its credential segment.
|
|
urls.append(
|
|
f"http://{local_host or '<home-assistant-ip>'}:{port}{secret_path}"
|
|
" (direct access)"
|
|
)
|
|
return urls
|
|
|
|
|
|
def _surface_connect_urls(
|
|
hass: HomeAssistant,
|
|
entry: ConfigEntry,
|
|
auth_mode: str,
|
|
*,
|
|
webhook_enabled: bool = True,
|
|
) -> None:
|
|
"""Log the connect URLs and (re)create a persistent notification."""
|
|
urls = build_connect_urls(hass, entry, webhook_enabled=webhook_enabled)
|
|
auth_note = (
|
|
"Webhook access is disabled (local-only mode)."
|
|
if not webhook_enabled
|
|
else "The webhook URL is the shared secret (no bearer required)."
|
|
if auth_mode == WEBHOOK_AUTH_NONE
|
|
else "Clients authenticate with your Home Assistant account (ha_auth)."
|
|
)
|
|
|
|
url_lines = "\n".join(f"- {url}" for url in urls)
|
|
_LOGGER.info(
|
|
"HA-MCP in-process server is running. Connect URL(s):\n%s\n%s",
|
|
url_lines,
|
|
auth_note,
|
|
)
|
|
if not bool(entry.options.get(OPT_ENABLE_STARTUP_NOTIFICATION, True)):
|
|
# Notification suppressed by option: clear any notification created
|
|
# before the toggle was turned off, then skip creating a fresh one. The
|
|
# connect URLs still reached the admin-only log above.
|
|
persistent_notification.async_dismiss(hass, _NOTIFICATION_ID)
|
|
return
|
|
# The sidebar-panel line is included only while the panel is registered:
|
|
# with the panel option off the /ha-mcp route does not exist and the link
|
|
# would 404.
|
|
panel_line = (
|
|
"Manage it from the [HA-MCP settings panel](/ha-mcp) in the sidebar.\n\n"
|
|
if bool(entry.options.get(OPT_ENABLE_SIDEBAR_PANEL, True))
|
|
else ""
|
|
)
|
|
# SECURITY (review finding): persistent notifications are visible to EVERY
|
|
# authenticated Home Assistant user - core's persistent_notification/get
|
|
# and /subscribe carry no admin gate. In the default posture the connect
|
|
# URL IS an admin-equivalent credential, so the notification deliberately
|
|
# carries NO secrets: it points at the admin-only surfaces (the sidebar
|
|
# panel and the entry's Configure screen). The URLs above still go to the
|
|
# log at INFO, which only admin-gated surfaces expose - the same posture
|
|
# as the add-on printing its URL to the admin-only add-on log.
|
|
message = (
|
|
"The HA-MCP Server is now running inside Home Assistant.\n\n"
|
|
f"{panel_line}"
|
|
"The connect URL is shown on the entry's Configure screen "
|
|
"(Settings - Devices & Services - HA-MCP Custom Component - "
|
|
"HA-MCP Server - Configure) and in the Home Assistant log - both "
|
|
"administrator-only, because the URL is the credential.\n\n"
|
|
f"{auth_note}\n"
|
|
)
|
|
persistent_notification.async_create(
|
|
hass,
|
|
message,
|
|
title="HA-MCP Server",
|
|
notification_id=_NOTIFICATION_ID,
|
|
)
|
|
|
|
|
|
_ISSUE_BY_KIND = {
|
|
"package": ISSUE_PACKAGE_FAILED,
|
|
"start": ISSUE_START_FAILED,
|
|
}
|
|
|
|
|
|
def _create_issue(hass: HomeAssistant, kind: str, detail: str) -> None:
|
|
"""File the repair issue matching the failure ``kind`` (package / start).
|
|
|
|
Exhaustive lookup on purpose: an unknown kind is a coding error and must
|
|
raise here rather than silently filing the wrong user-facing repair issue.
|
|
"""
|
|
issue_id = _ISSUE_BY_KIND[kind]
|
|
ir.async_create_issue(
|
|
hass,
|
|
DOMAIN,
|
|
issue_id,
|
|
is_fixable=False,
|
|
severity=ir.IssueSeverity.ERROR,
|
|
translation_key=issue_id,
|
|
translation_placeholders={"detail": detail},
|
|
)
|
|
|
|
|
|
def _clear_issues(hass: HomeAssistant) -> None:
|
|
"""Clear any previously-filed server-bring-up repair issues."""
|
|
for issue_id in _ISSUE_IDS:
|
|
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Automatic server-version updates (channel auto-update)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def async_maybe_auto_update(
|
|
hass: HomeAssistant, entry: ConfigEntry, info: ServerVersionInfo | None
|
|
) -> None:
|
|
"""Reload the entry when ``info`` shows a newer build AND auto-update is on.
|
|
|
|
Called from the :class:`~.coordinator.ServerVersionCoordinator` listener
|
|
registered by :mod:`embedded_entry` on every refresh (every
|
|
``UPDATE_CHECK_INTERVAL``, plus once shortly after setup). The coordinator
|
|
itself always fetches (see its docstring) so the `update` platform entity
|
|
stays populated regardless of this option; only the reload decided here is
|
|
gated on it.
|
|
|
|
Skips entirely when: auto-update is off, a pip-spec override is set,
|
|
either version is unknown (``info`` may still be ``None`` — the
|
|
coordinator's ``data`` type before its first successful refresh), or a
|
|
bring-up is still in flight (below).
|
|
|
|
A pending update is additionally gated on component compatibility
|
|
(issues #1783/#1785): when the candidate release also shipped a newer
|
|
custom component than the one running, the reload is HELD — loudly (a
|
|
repair issue plus a warning log every check) and escapably (applying the
|
|
HACS component update — which takes an HA restart, as the issue text
|
|
says — unblocks the next check; the update entity's Install button never
|
|
passes through here, so manual installs — like pip-spec overrides above —
|
|
bypass the hold entirely). Every failure inside the gate fails OPEN so a
|
|
GitHub hiccup can never wedge updates.
|
|
|
|
Best-effort: an incomparable version string (AwesomeVersionException) is
|
|
logged at debug and skipped; the next refresh retries. Genuine bugs
|
|
propagate per the repo's no-silent-failure convention.
|
|
"""
|
|
if not bool(entry.options.get(OPT_AUTO_UPDATE, DEFAULT_AUTO_UPDATE)):
|
|
# Auto-update turned off: stay on the currently-installed version.
|
|
return
|
|
|
|
override = str(entry.options.get(OPT_PIP_SPEC) or "").strip()
|
|
if override and override != DEFAULT_PIP_SPEC:
|
|
return
|
|
|
|
if info is None or info.installed is None or info.latest is None:
|
|
# Nothing to compare (not installed yet, or the PyPI fetch failed /
|
|
# was skipped) - the bring-up path installs the newest build itself.
|
|
return
|
|
|
|
bringup_task = hass.data.get(DOMAIN, {}).get(DATA_BRINGUP_TASK)
|
|
if bringup_task is not None and not bringup_task.done():
|
|
# The coordinator's first refresh runs shortly after setup, while the
|
|
# background bring-up (embedded_entry.async_setup_server_entry) may
|
|
# still be installing the package for the first time. Reloading here
|
|
# would cancel that in-flight install (async_unload_server_entry
|
|
# cancels the bring-up task on unload) and can loop: the reload's own
|
|
# bring-up starts a fresh install that the NEXT refresh could again
|
|
# interrupt.
|
|
return
|
|
|
|
try:
|
|
newer = AwesomeVersion(info.latest) > AwesomeVersion(info.installed)
|
|
except AwesomeVersionException as err:
|
|
# Incomparable version strategies (e.g. a non-semver build string) — the
|
|
# only expected failure here. Real bugs (TypeError, etc.) propagate.
|
|
_LOGGER.debug("HA-MCP auto-update version compare failed: %s", err)
|
|
return
|
|
|
|
if not newer:
|
|
# Up to date: a hold that was pending is resolved (the component
|
|
# update landed and the unblocked reload installed the server).
|
|
ir.async_delete_issue(hass, DOMAIN, ISSUE_UPDATE_HELD)
|
|
return
|
|
|
|
held = await _async_update_held_by_component(hass, info)
|
|
if held is not None:
|
|
shipped, running = held
|
|
_LOGGER.warning(
|
|
"HA-MCP server %s is available, but that release also updated the "
|
|
"custom component (%s; running %s); holding the automatic server "
|
|
"update until the component is updated via HACS. Press Install on "
|
|
"the HA-MCP server update entity to install anyway.",
|
|
info.latest,
|
|
shipped,
|
|
running,
|
|
)
|
|
ir.async_create_issue(
|
|
hass,
|
|
DOMAIN,
|
|
ISSUE_UPDATE_HELD,
|
|
is_fixable=False,
|
|
severity=ir.IssueSeverity.WARNING,
|
|
translation_key=ISSUE_UPDATE_HELD,
|
|
translation_placeholders={
|
|
"latest": str(info.latest),
|
|
"shipped": shipped,
|
|
"running": running,
|
|
},
|
|
learn_more_url=HACS_COMPONENT_URL,
|
|
)
|
|
return
|
|
ir.async_delete_issue(hass, DOMAIN, ISSUE_UPDATE_HELD)
|
|
|
|
channel = channel_for_dist(info.dist)
|
|
_LOGGER.info(
|
|
"HA-MCP server update available on the %s channel (%s -> %s); "
|
|
"reloading the entry to install it.",
|
|
channel,
|
|
info.installed,
|
|
info.latest,
|
|
)
|
|
# The "updated" notification must wait for the reloaded entry's bring-up to
|
|
# actually install and start the new build — async_reload returns when
|
|
# entry SETUP finishes, while the pip install still runs in the background
|
|
# and can fail (review finding). Leave a marker for bring-up to pop:
|
|
# notification on success (_async_finish_update_cycle), silent drop on
|
|
# failure (the package/start repair issues cover that path).
|
|
hass.data.setdefault(DOMAIN, {})[DATA_PENDING_UPDATE_NOTIFY] = {
|
|
"old": info.installed
|
|
}
|
|
try:
|
|
await hass.config_entries.async_reload(entry.entry_id)
|
|
except Exception:
|
|
# A raising reload leaves no repair issue behind (those are filed by
|
|
# bring-up, which never ran), so this ERROR log is the only signal —
|
|
# it must not be swallowed or left at debug (review finding). The next
|
|
# coordinator refresh retries the whole cycle.
|
|
_drop_pending_update_notify(hass)
|
|
_LOGGER.exception(
|
|
"HA-MCP auto-update reload failed (%s -> %s on the %s channel)",
|
|
info.installed,
|
|
info.latest,
|
|
channel,
|
|
)
|
|
|
|
|
|
def _drop_pending_update_notify(hass: HomeAssistant) -> None:
|
|
"""Drop the deferred update-notification marker without notifying."""
|
|
hass.data.get(DOMAIN, {}).pop(DATA_PENDING_UPDATE_NOTIFY, None)
|
|
|
|
|
|
async def _async_update_held_by_component(
|
|
hass: HomeAssistant, info: ServerVersionInfo
|
|
) -> tuple[str, str] | None:
|
|
"""Return ``(shipped, running)`` when the pending update must be held.
|
|
|
|
The #1783/#1785 breakage: a server release whose repo state also bumped the
|
|
custom component auto-installed under the OLD component before HACS had
|
|
even surfaced the component update. The component version in the manifest
|
|
at the candidate release's git tag is what shipped with that server build —
|
|
newer than the running component means the release changed the component
|
|
too, so the automatic server install waits for the component.
|
|
|
|
Fails OPEN (returns None → install proceeds, the pre-gate behavior) on
|
|
every expected failure: manifest unreachable, component version unreadable,
|
|
incomparable versions. Blocking updates indefinitely on a transient would
|
|
be worse than the crash this guards against — and the crash itself is now
|
|
also survivable server-side (the tools registry skips a failing module).
|
|
"""
|
|
shipped = await _async_fetch_shipped_component_version(hass, str(info.latest))
|
|
if shipped is None:
|
|
return None
|
|
|
|
try:
|
|
integration = await async_get_integration(hass, DOMAIN)
|
|
running = str(integration.version)
|
|
except Exception:
|
|
# Same wide loader surface as _async_check_component_compat: advisory
|
|
# gate, logged visibly rather than swallowed silently.
|
|
_LOGGER.warning(
|
|
"Could not read the HA-MCP component version for the auto-update "
|
|
"gate; proceeding with the update",
|
|
exc_info=True,
|
|
)
|
|
return None
|
|
|
|
try:
|
|
if AwesomeVersion(running) < AwesomeVersion(shipped):
|
|
return shipped, running
|
|
except AwesomeVersionException as err:
|
|
# Incomparable version strategies only; real bugs propagate.
|
|
_LOGGER.debug("HA-MCP auto-update gate version compare failed: %s", err)
|
|
return None
|
|
|
|
|
|
async def _async_fetch_shipped_component_version(
|
|
hass: HomeAssistant, server_version: str
|
|
) -> str | None:
|
|
"""Return the component version shipped at server release ``vX.Y.Z``.
|
|
|
|
Reads the component manifest as committed at the release's git tag (raw
|
|
GitHub URL). Stable tags exist before the PyPI publish; a dev tag only
|
|
appears after its binary builds finish, so a fresh dev version can 404
|
|
here for some minutes — see COMPONENT_MANIFEST_AT_TAG_URL. Returns None
|
|
on any failure; the caller treats that as "nothing to hold on"
|
|
(fail-open).
|
|
"""
|
|
url = COMPONENT_MANIFEST_AT_TAG_URL.format(version=server_version)
|
|
try:
|
|
session = async_get_clientsession(hass)
|
|
async with asyncio.timeout(_MANIFEST_FETCH_TIMEOUT_SECONDS):
|
|
async with session.get(url) as resp:
|
|
resp.raise_for_status()
|
|
# content_type=None: raw.githubusercontent.com serves
|
|
# text/plain, which aiohttp's default json() rejects.
|
|
payload = await resp.json(content_type=None)
|
|
return str(payload["version"])
|
|
except (ClientError, TimeoutError, KeyError, TypeError, ValueError) as err:
|
|
_LOGGER.debug(
|
|
"HA-MCP shipped-component manifest fetch failed for %s: %s", url, err
|
|
)
|
|
return None
|
|
|
|
|
|
async def _async_finish_update_cycle(hass: HomeAssistant) -> None:
|
|
"""Refresh the version entity and fire the deferred update notification.
|
|
|
|
Runs at the end of a fully successful bring-up. Both halves belong exactly
|
|
here (review findings): the freshly installed version is only knowable once
|
|
the install landed — without a refresh the `update` entity keeps showing a
|
|
stale "update available" for up to UPDATE_CHECK_INTERVAL after a successful
|
|
install — and the notification deferred by async_maybe_auto_update must
|
|
only fire for an install that actually happened. Advisory: a failure here
|
|
must never fail the (already running) server, so it is logged visibly and
|
|
swallowed. No reload loop: the refresh's listener re-enters
|
|
async_maybe_auto_update, which no-ops on the still-running bring-up task.
|
|
"""
|
|
domain_data = hass.data.get(DOMAIN, {})
|
|
coordinator = domain_data.get(DATA_UPDATE_COORDINATOR)
|
|
try:
|
|
if coordinator is not None:
|
|
await coordinator.async_refresh()
|
|
except Exception:
|
|
_LOGGER.warning("HA-MCP: post-install version refresh failed", exc_info=True)
|
|
marker = domain_data.pop(DATA_PENDING_UPDATE_NOTIFY, None)
|
|
if marker is None or coordinator is None or coordinator.data is None:
|
|
return
|
|
installed = coordinator.data.installed
|
|
old = marker.get("old")
|
|
if installed is None or installed == old:
|
|
# The reload ran but the installed version did not actually move (the
|
|
# install can legitimately resolve to the same build) — an "updated
|
|
# to" notification would be false.
|
|
return
|
|
_create_update_notification(
|
|
hass, channel_for_dist(coordinator.data.dist), old, installed
|
|
)
|
|
|
|
|
|
def _create_update_notification(
|
|
hass: HomeAssistant, channel: str, old_version: str, new_version: str
|
|
) -> None:
|
|
"""Notify that an automatic server update installed and the server is up.
|
|
|
|
Only called from :func:`_async_finish_update_cycle` after a successful
|
|
bring-up, so the versions are the confirmed before/after pair, never a
|
|
prediction. SECURITY: same posture as ``_surface_connect_urls`` -
|
|
persistent notifications are visible to every authenticated Home Assistant
|
|
user, so this carries no secrets or connect URLs, only version numbers and
|
|
a public GitHub link.
|
|
"""
|
|
release_url = (
|
|
"https://github.com/homeassistant-ai/ha-mcp/commits/master"
|
|
if channel == CHANNEL_DEV
|
|
else f"https://github.com/homeassistant-ai/ha-mcp/releases/tag/v{new_version}"
|
|
)
|
|
message = (
|
|
f"The HA-MCP server was automatically updated from {old_version} to "
|
|
f"{new_version} on the {channel} channel.\n\n"
|
|
f"[Release notes]({release_url})"
|
|
)
|
|
persistent_notification.async_create(
|
|
hass,
|
|
message,
|
|
title="HA-MCP Server updated",
|
|
notification_id=_UPDATE_NOTIFICATION_ID,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Component / server version-compatibility repair issue
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _read_min_component_version() -> str | None:
|
|
"""Return the server's declared ``MIN_COMPONENT_VERSION``, or None (blocking).
|
|
|
|
Imported here (in an executor thread) so the heavy ``ha_mcp`` import stays
|
|
off the event loop and out of this module's top level. Guards older/newer
|
|
server layouts that do not expose the constant by returning None (skip).
|
|
"""
|
|
try:
|
|
from ha_mcp.tools.tools_filesystem import MIN_COMPONENT_VERSION
|
|
except (ImportError, AttributeError):
|
|
return None
|
|
return str(MIN_COMPONENT_VERSION)
|
|
|
|
|
|
async def _async_check_component_compat(
|
|
hass: HomeAssistant, entry: ConfigEntry
|
|
) -> None:
|
|
"""File/clear the component-outdated repair issue for the running server.
|
|
|
|
The ha-mcp server declares the minimum custom-component version it needs
|
|
(``MIN_COMPONENT_VERSION``). The server package updates independently of
|
|
the HACS component (this manager pip-installs new server builds), so the
|
|
running component can lag what the server expects. When it does, surface a
|
|
WARNING repair issue pointing at the HACS component update; clear it once
|
|
the component is new enough.
|
|
|
|
Advisory only — it must never block or fail server startup, so an
|
|
unexpected error is logged (visible, not silent) and swallowed rather than
|
|
propagated to the bring-up's failure handling.
|
|
"""
|
|
required = await hass.async_add_executor_job(_read_min_component_version)
|
|
if required is None:
|
|
# Server predates MIN_COMPONENT_VERSION, or a newer layout moved it —
|
|
# nothing to enforce.
|
|
return
|
|
|
|
try:
|
|
integration = await async_get_integration(hass, DOMAIN)
|
|
own = str(integration.version)
|
|
except Exception:
|
|
# The loader legitimately raises a wide, varied surface
|
|
# (IntegrationNotFound, manifest errors); advisory check, logged
|
|
# visibly with the traceback rather than swallowed silently.
|
|
_LOGGER.warning(
|
|
"Could not read the HA-MCP component version for the compatibility check",
|
|
exc_info=True,
|
|
)
|
|
return
|
|
|
|
try:
|
|
outdated = AwesomeVersion(own) < AwesomeVersion(required)
|
|
except AwesomeVersionException as err:
|
|
# Incomparable version strategies only; real bugs propagate.
|
|
_LOGGER.debug("HA-MCP component-compat version compare failed: %s", err)
|
|
return
|
|
|
|
if outdated:
|
|
_LOGGER.warning(
|
|
"The installed ha-mcp server requires HA-MCP Custom Component %s or "
|
|
"newer, but %s is running; update the component via HACS.",
|
|
required,
|
|
own,
|
|
)
|
|
ir.async_create_issue(
|
|
hass,
|
|
DOMAIN,
|
|
ISSUE_COMPONENT_OUTDATED,
|
|
is_fixable=False,
|
|
severity=ir.IssueSeverity.WARNING,
|
|
translation_key=ISSUE_COMPONENT_OUTDATED,
|
|
translation_placeholders={"required": required, "installed": own},
|
|
learn_more_url=HACS_COMPONENT_URL,
|
|
)
|
|
else:
|
|
ir.async_delete_issue(hass, DOMAIN, ISSUE_COMPONENT_OUTDATED)
|