23 files
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -25,7 +25,7 @@ DOMAIN = "ha_mcp_tools"
|
||||
# in CI. The
|
||||
# capability negotiation — not this version — gates each WS command (see
|
||||
# ``websocket_api.CAPABILITIES``).
|
||||
COMPONENT_VERSION = "1.3.0"
|
||||
COMPONENT_VERSION = "1.3.1"
|
||||
|
||||
# Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value
|
||||
# means "tools" so the pre-existing services entry keeps working across the
|
||||
|
||||
@@ -38,7 +38,7 @@ import threading
|
||||
from contextlib import suppress
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from homeassistant.auth.const import GROUP_ID_ADMIN
|
||||
from homeassistant.auth.models import TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN
|
||||
@@ -116,6 +116,16 @@ _READY_POLL_INTERVAL_SECONDS = 0.5
|
||||
# leaking it rather than blocking HA shutdown.
|
||||
_STOP_JOIN_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
# Budget for each teardown phase (the _serve resource cleanup and the
|
||||
# worker-loop pending-task sweep). Mirrors the CLI runner's
|
||||
# SHUTDOWN_TIMEOUT_SECONDS: both phases together must finish inside
|
||||
# _STOP_JOIN_TIMEOUT_SECONDS, or async_stop declares the worker orphaned
|
||||
# while the old thread is still executing shared ha_mcp modules. The budget
|
||||
# bounds only the phases that accept one — asyncgen finalization and
|
||||
# uvicorn's post-drain lifespan shutdown remain unbounded — so it buys
|
||||
# headroom, not a hard ceiling on the join.
|
||||
_TEARDOWN_TIMEOUT_SECONDS = 2.0
|
||||
|
||||
# Per-download HTTP timeout for a forced reinstall. The first install pulls the
|
||||
# whole fastmcp tree, well beyond HA's 60s requirements default.
|
||||
_PIP_INSTALL_TIMEOUT_SECONDS = 300
|
||||
@@ -1201,24 +1211,7 @@ class EmbeddedServerManager:
|
||||
finally:
|
||||
with _IMPORTING_WORKERS_LOCK:
|
||||
_IMPORTING_WORKERS.discard(threading.current_thread())
|
||||
# Teardown is best-effort but never SILENT (review finding): a
|
||||
# raise here must not mask the primary outcome, yet a recurring
|
||||
# cleanup failure (leaking executor threads across reloads) has
|
||||
# to be visible in the logs. Each call gets its own guard so one
|
||||
# failure cannot skip the other.
|
||||
for _label, _coro_factory in (
|
||||
("asyncgen", loop.shutdown_asyncgens),
|
||||
("executor", loop.shutdown_default_executor),
|
||||
):
|
||||
try:
|
||||
loop.run_until_complete(_coro_factory())
|
||||
except Exception:
|
||||
_LOGGER.warning(
|
||||
"Worker-loop %s shutdown failed during teardown",
|
||||
_label,
|
||||
exc_info=True,
|
||||
)
|
||||
loop.close()
|
||||
_teardown_worker_loop(loop)
|
||||
|
||||
async def _serve(self, access_token: str, stop_event: asyncio.Event) -> None:
|
||||
"""Build the ha-mcp server and run it until a stop is signaled.
|
||||
@@ -1396,23 +1389,33 @@ class EmbeddedServerManager:
|
||||
|
||||
self._note_startup_phase("starting the HTTP listener")
|
||||
stop_task = asyncio.create_task(stop_event.wait())
|
||||
async with server.mcp._lifespan_manager():
|
||||
serve_task = asyncio.create_task(uv_server.serve())
|
||||
done, _pending = await asyncio.wait(
|
||||
{serve_task, stop_task}, return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
if stop_task in done:
|
||||
# Graceful shutdown through uvicorn's own path: waits out
|
||||
# in-flight requests (2s cap), runs lifespan shutdown, and
|
||||
# deterministically releases the socket for the next bring-up.
|
||||
uv_server.should_exit = True
|
||||
await serve_task
|
||||
else:
|
||||
stop_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await stop_task
|
||||
# Surface a server that exited on its own (bind failure, etc.).
|
||||
serve_task.result()
|
||||
try:
|
||||
async with server.mcp._lifespan_manager():
|
||||
serve_task = asyncio.create_task(uv_server.serve())
|
||||
done, _pending = await asyncio.wait(
|
||||
{serve_task, stop_task}, return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
if stop_task in done:
|
||||
# Graceful shutdown through uvicorn's own path: waits out
|
||||
# in-flight requests (2s cap), runs lifespan shutdown, and
|
||||
# deterministically releases the socket for the next
|
||||
# bring-up.
|
||||
uv_server.should_exit = True
|
||||
await serve_task
|
||||
else:
|
||||
stop_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await stop_task
|
||||
# Surface a server that exited on its own (bind failure,
|
||||
# etc.).
|
||||
serve_task.result()
|
||||
finally:
|
||||
# CLI parity: the HTTP runner's shutdown path releases the served
|
||||
# stack's HA connections; this in-process runner must too, on this
|
||||
# loop, while it still runs — otherwise the reader tasks are only
|
||||
# cancelled by the thread's loop teardown and their sockets are
|
||||
# abandoned to garbage collection (issue #2127).
|
||||
await _shutdown_server_resources_bounded(server)
|
||||
|
||||
def _progress_signature(self) -> tuple[int, str]:
|
||||
"""Snapshot the observable startup progress of the worker thread.
|
||||
@@ -1526,6 +1529,149 @@ _IMPORTING_WORKERS_LOCK = threading.Lock()
|
||||
_IMPORTING_WORKERS: set[threading.Thread] = set()
|
||||
|
||||
|
||||
async def _shutdown_server_resources_bounded(server: Any) -> None:
|
||||
"""Run :func:`_shutdown_server_resources` inside the teardown budget.
|
||||
|
||||
An unresponsive peer's close handshake (websockets' 10s default
|
||||
close_timeout) must not eat the whole ``_STOP_JOIN_TIMEOUT_SECONDS`` join
|
||||
budget. Cancel-and-abandon, not ``wait_for``: ``wait_for`` awaits the
|
||||
cancelled coroutine before raising, and the cleanup stack swallows
|
||||
``CancelledError`` at several layers (per-client in
|
||||
``WebSocketManager.disconnect``, in ``client.disconnect``'s own
|
||||
task-cancel guard), so a straggler must be left to the thread's loop
|
||||
teardown sweep instead of being joined here.
|
||||
"""
|
||||
task = asyncio.ensure_future(_shutdown_server_resources(server))
|
||||
_done, pending = await asyncio.wait({task}, timeout=_TEARDOWN_TIMEOUT_SECONDS)
|
||||
if pending:
|
||||
task.cancel()
|
||||
_LOGGER.warning("Embedded resource cleanup timed out")
|
||||
|
||||
|
||||
async def _shutdown_server_resources(server: Any) -> None:
|
||||
"""Release the served stack's Home Assistant connections on its own loop.
|
||||
|
||||
Mirrors the CLI runner's ``_cleanup_resources`` (``ha_mcp.__main__``)
|
||||
without importing it: stop the WebSocket listener service, disconnect the
|
||||
pooled WebSocket clients, and close the server's HTTP client. Every step
|
||||
guards independently — a failing step must not keep the next one from
|
||||
running, and no failure here may mask the serve outcome.
|
||||
"""
|
||||
try:
|
||||
from ha_mcp.client.websocket_listener import stop_websocket_listener
|
||||
|
||||
await stop_websocket_listener()
|
||||
except ImportError:
|
||||
_LOGGER.debug("WebSocket listener module not available")
|
||||
except Exception as err:
|
||||
_LOGGER.warning("WebSocket listener cleanup failed: %s", err)
|
||||
|
||||
try:
|
||||
from ha_mcp.client.websocket_client import websocket_manager
|
||||
|
||||
await websocket_manager.disconnect()
|
||||
except ImportError:
|
||||
_LOGGER.debug("WebSocket manager module not available")
|
||||
except Exception as err:
|
||||
_LOGGER.warning("WebSocket manager cleanup failed: %s", err)
|
||||
|
||||
try:
|
||||
await server.close()
|
||||
except Exception as err:
|
||||
_LOGGER.warning("Server cleanup failed: %s", err)
|
||||
|
||||
|
||||
def _cancel_pending_tasks(loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""Cancel every task still pending on ``loop`` and wait them out.
|
||||
|
||||
Mirrors ``asyncio.runners._cancel_all_tasks`` — the step ``asyncio.run``
|
||||
performs between the main coroutine returning and asyncgen finalization,
|
||||
which this worker's hand-rolled loop lifecycle skipped (issue #2127).
|
||||
Without it, tasks the served stack leaves behind — WebSocket reader tasks
|
||||
parked in ``Connection.__aiter__``, sse_starlette's ``_shutdown_watcher``
|
||||
poll (unreachable by its uvicorn signal hooks on a non-main thread) — are
|
||||
still pending at teardown: ``shutdown_asyncgens()`` then acloses
|
||||
generators mid-``__anext__`` (``RuntimeError: aclose(): asynchronous
|
||||
generator is already running``) and ``loop.close()`` destroys the
|
||||
survivors ("Task was destroyed but it is pending!"), one error pair per
|
||||
entry reload.
|
||||
|
||||
Abandoning is inherently partial: a task that ignores cancellation past
|
||||
the budget and still drives an async generator leaves that generator
|
||||
running, and ``shutdown_asyncgens()`` then reports the same ``aclose()``
|
||||
error this sweep exists to remove. The ignored-cancellation warning
|
||||
below is the tell when that residual fires.
|
||||
"""
|
||||
pending = asyncio.all_tasks(loop)
|
||||
if not pending:
|
||||
return
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
# Bounded, unlike asyncio.runners: async_stop joins this worker for only
|
||||
# _STOP_JOIN_TIMEOUT_SECONDS, so a task that ignores cancellation gets
|
||||
# logged and abandoned rather than hanging the join (the CLI's
|
||||
# _cancel_tasks does the same, issue #2027 precedent).
|
||||
done, still_pending = loop.run_until_complete(
|
||||
asyncio.wait(pending, timeout=_TEARDOWN_TIMEOUT_SECONDS)
|
||||
)
|
||||
if still_pending:
|
||||
_LOGGER.warning(
|
||||
"%d task(s) ignored cancellation during worker-loop teardown",
|
||||
len(still_pending),
|
||||
)
|
||||
for task in done:
|
||||
if task.cancelled():
|
||||
continue
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
_LOGGER.warning(
|
||||
"Task %r raised during worker-loop teardown: %r",
|
||||
task.get_name(),
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
def _teardown_worker_loop(loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""Drain and close the worker loop with ``asyncio.run`` teardown parity.
|
||||
|
||||
Teardown is best-effort but never SILENT (review finding): a raise here
|
||||
must not mask the primary outcome, yet a recurring cleanup failure
|
||||
(leaking executor threads across reloads) has to be visible in the logs.
|
||||
Each step gets its own guard so one failure cannot skip the others.
|
||||
"""
|
||||
try:
|
||||
_cancel_pending_tasks(loop)
|
||||
except Exception:
|
||||
_LOGGER.warning(
|
||||
"Worker-loop task cancellation failed during teardown",
|
||||
exc_info=True,
|
||||
)
|
||||
for _label, _coro_factory in (
|
||||
("asyncgen", loop.shutdown_asyncgens),
|
||||
# The executor join is bounded too: a stuck executor thread must not
|
||||
# keep the worker alive past the join deadline (abandoning it emits
|
||||
# a RuntimeWarning instead of hanging). The runtime has accepted
|
||||
# timeout= since Python 3.12; typeshed's AbstractEventLoop signature
|
||||
# lags behind, hence the scoped ignore.
|
||||
(
|
||||
"executor",
|
||||
partial(
|
||||
loop.shutdown_default_executor,
|
||||
timeout=_TEARDOWN_TIMEOUT_SECONDS, # type: ignore[call-arg]
|
||||
),
|
||||
),
|
||||
):
|
||||
try:
|
||||
loop.run_until_complete(_coro_factory())
|
||||
except Exception:
|
||||
_LOGGER.warning(
|
||||
"Worker-loop %s shutdown failed during teardown",
|
||||
_label,
|
||||
exc_info=True,
|
||||
)
|
||||
loop.close()
|
||||
|
||||
|
||||
def _prune_and_check_importing_workers() -> bool:
|
||||
"""Drop dead workers from the registry; return True if any live one remains."""
|
||||
with _IMPORTING_WORKERS_LOCK:
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"requirements": [
|
||||
"ruamel.yaml>=0.18.0"
|
||||
],
|
||||
"version": "1.3.0"
|
||||
"version": "1.3.1"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "HA-MCP 自定义组件",
|
||||
"description": "选择要添加的内容。**HA-MCP 服务器**在 Home Assistant 内部运行完整的 ha-mcp 服务器,并通过 Home Assistant Webhook 对外提供服务——这是大多数用户需要的安装方式。它是一个独立的服务器,可完全取代其他所有安装方式(插件、Docker、uvx/PyPI、stdio);如果运行它,就不要再同时运行其中任何一种。**HA-MCP 文件与 YAML 工具**添加特权文件和 YAML 编辑服务,仅在你启用 ha-mcp 的可选文件/YAML 工具时才需要。如果你的 ha-mcp 服务器已在别处运行(插件、Docker、uvx),则不需要服务器条目——只添加这个文件与 YAML 条目,并且仅在你使用这些工具时才添加。它适用于所有服务器类型,随时可以后续添加。",
|
||||
"description": "选择要添加的内容。**HA-MCP 服务器**在 Home Assistant 内部运行完整的 ha-mcp 服务器,并通过 Home Assistant Webhook 对外提供服务——这是大多数用户需要的安装方式。它是一个独立的服务器,可完全取代其他所有安装方式(插件、Docker、uvx/PyPI、stdio);如果运行它,就不要再同时运行其中任何一种。**HA-MCP 文件与 YAML 工具**添加特权文件和 YAML 编辑服务,仅在您启用 ha-mcp 的可选文件/YAML 工具时才需要。如果您的 ha-mcp 服务器已在别处运行(插件、Docker、uvx),则不需要服务器条目——只添加这个文件与 YAML 条目,并且仅在您使用这些工具时才添加。它适用于所有服务器类型,随时可以后续添加。",
|
||||
"menu_options": {
|
||||
"server": "HA-MCP 服务器(推荐)",
|
||||
"tools": "HA-MCP 文件与 YAML 工具(可选)"
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"tools": {
|
||||
"title": "HA-MCP 文件与 YAML 工具",
|
||||
"description": "设置特权文件和 YAML 配置服务。仅在你启用 ha-mcp 的可选文件/YAML 编辑工具(功能开关,默认关闭)时才需要——这适用于所有服务器类型,包括进程内的 HA-MCP 服务器。你可以随时添加或移除此条目。"
|
||||
"description": "设置特权文件和 YAML 配置服务。仅在您启用 ha-mcp 的可选文件/YAML 编辑工具(功能开关,默认关闭)时才需要——这适用于所有服务器类型,包括进程内的 HA-MCP 服务器。您可以随时添加或移除此条目。"
|
||||
},
|
||||
"server": {
|
||||
"title": "HA-MCP 服务器",
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "此条目已配置。",
|
||||
"unsupported_home_assistant": "进程内 HA-MCP 服务器需要 Home Assistant {required} 或更高版本,但此实例运行的是 {installed}。你仍可将本组件的 HA-MCP File & YAML Tools 条目与以插件或 Docker 容器方式运行的外部 ha-mcp 服务器搭配使用。"
|
||||
"unsupported_home_assistant": "进程内 HA-MCP 服务器需要 Home Assistant {required} 或更高版本,但此实例运行的是 {installed}。您仍可将本组件的 HA-MCP File & YAML Tools 条目与以插件或 Docker 容器方式运行的外部 ha-mcp 服务器搭配使用。"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -63,21 +63,21 @@
|
||||
},
|
||||
"data_description": {
|
||||
"channel": "稳定版安装最新的稳定发行版;开发版安装最新的开发构建。启用自动更新后,重新加载或重启以及定期检查都会安装所选通道的最新构建。下方的开发者软件包覆盖优先级更高,并会禁用自动更新。",
|
||||
"auto_update": "启用时,所选通道的最新发行版会自动安装——在重新加载或重启时,以及通过定期检查。关闭时,服务器会保持当前已安装的版本,直到你重新启用此项。它仅控制 ha-mcp 服务器软件包;HA-MCP 自定义组件自身的更新仍通过 HACS 进行。",
|
||||
"server_port": "此服务器监听的端口。ha-mcp 插件使用 9583,因此这里默认为 9584,以便两者并存——如果你不运行该插件,任何空闲端口都可以。",
|
||||
"auto_update": "启用时,所选通道的最新发行版会自动安装——在重新加载或重启时,以及通过定期检查。关闭时,服务器会保持当前已安装的版本,直到您重新启用此项。它仅控制 ha-mcp 服务器软件包;HA-MCP 自定义组件自身的更新仍通过 HACS 进行。",
|
||||
"server_port": "此服务器监听的端口。ha-mcp 插件使用 9583,因此这里默认为 9584,以便两者并存——如果您不运行该插件,任何空闲端口都可以。",
|
||||
"bind_host": "谁可以直接连接到 MCP 服务器端口。默认值与插件一致:可在本地网络中访问,并以密钥路径作为凭据。选择 loopback 则仅允许来自 Home Assistant 所在主机的连接——无论哪种方式,Webhook URL 和侧边栏面板都能正常工作。",
|
||||
"webhook_auth": "MCP 客户端在 Webhook URL 上如何证明自己的身份。使用密钥 URL 时,链接本身就是凭据。使用 Home Assistant 登录时,claude.ai 等客户端会以 Home Assistant 管理员账户登录(OAuth)。使用旧版 OAuth 时,此集成会签发自己的 Client ID 和 Secret,供需要它们的客户端(例如 Google Gemini Spark)粘贴使用——开启或关闭该模式需要重启 Home Assistant。",
|
||||
"oauth_client_id_override": "替换自动生成的旧版 OAuth Client ID。留空则保留当前值。仅在身份验证模式设为旧版 OAuth 时使用。",
|
||||
"oauth_client_secret_override": "替换自动生成的旧版 OAuth Client Secret。留空则保留当前值。仅在身份验证模式设为旧版 OAuth 时使用。",
|
||||
"oauth_regenerate": "一次性操作:为旧版 OAuth 模式生成新的 Client ID 和 Secret。它只有在修复提示所要求的 Home Assistant 重启之后才会生效——在你重启之前,原有的 Client ID 和 Secret 仍然有效,新的尚未启用。同时会清空上面两个覆盖字段。",
|
||||
"oauth_regenerate": "一次性操作:为旧版 OAuth 模式生成新的 Client ID 和 Secret。它只有在修复提示所要求的 Home Assistant 重启之后才会生效——在您重启之前,原有的 Client ID 和 Secret 仍然有效,新的尚未启用。同时会清空上面两个覆盖字段。",
|
||||
"pip_spec": "请留空。仅用于测试特定的 ha-mcp 构建(例如固定到某个预发行版);它会覆盖发布通道,并在清空之前禁用自动更新。",
|
||||
"server_url": "进程内服务器用于访问你的 Home Assistant 的 URL(通常就是此实例本身)。留空则根据此实例的端口和 SSL 设置推导;仅在服务器需要走其他路由时才设置。",
|
||||
"external_url": "作为主要连接 URL 显示——当 Home Assistant 位于你自己的域名或反向代理之后时使用。请输入包含协议在内的完整基础地址。它必须直接指向 Home Assistant——在浏览器中打开它应能到达你的 HA 登录页面——并且不能包含 :8123 之类的端口(任何端口都不行),否则远程 MCP 客户端将无法访问。留空则自动使用 Nabu Casa / 本地地址。",
|
||||
"server_url": "进程内服务器用于访问您的 Home Assistant 的 URL(通常就是此实例本身)。留空则根据此实例的端口和 SSL 设置推导;仅在服务器需要走其他路由时才设置。",
|
||||
"external_url": "作为主要连接 URL 显示——当 Home Assistant 位于您自己的域名或反向代理之后时使用。请输入包含协议在内的完整基础地址。它必须直接指向 Home Assistant——在浏览器中打开它应能到达您的 HA 登录页面——并且不能包含 :8123 之类的端口(任何端口都不行),否则远程 MCP 客户端将无法访问。留空则自动使用 Nabu Casa / 本地地址。",
|
||||
"webhook_id_override": "替换连接 URL(/api/webhook/...)中随机生成的 Webhook 密钥。该 URL 就是凭据——请使用足够长、难以猜测的值。留空则保留当前值。",
|
||||
"secret_path_override": "替换在服务器端口上用于直连的随机路径。规则相同:该路径就是凭据。留空则保留当前值。",
|
||||
"regenerate_secrets": "一次性操作:生成新的随机 Webhook 密钥和直连路径,并立即使旧的连接 URL 失效。同时会清空上面两个覆盖字段。",
|
||||
"enable_webhook": "关闭以进入仅本地模式:完全不注册 Home Assistant Webhook,因此任何一方——包括 Nabu Casa——都无法通过 Home Assistant 访问服务器。服务器端口直连和侧边栏面板仍可继续使用。",
|
||||
"enable_llm_api": "向 Home Assistant 对话智能体(OpenAI、Google、Ollama 等)提供完整工具集:启用后,智能体可在“控制 Home Assistant”下选择“HA-MCP Server”,并在 Assist 聊天和语音中使用这些工具。启用只是让它可供选择——在你为某个智能体选中它之前,不会暴露任何内容。使用指南:{llm_api_docs_url}",
|
||||
"enable_llm_api": "向 Home Assistant 对话智能体(OpenAI、Google、Ollama 等)提供完整工具集:启用后,智能体可在“控制 Home Assistant”下选择“HA-MCP Server”,并在 Assist 聊天和语音中使用这些工具。启用只是让它可供选择——在您为某个智能体选中它之前,不会暴露任何内容。使用指南:{llm_api_docs_url}",
|
||||
"llm_api_exposure": "提供给对话智能体的工具集形态。工具搜索(默认)可让智能体的上下文保持精简:一个包含固定工具以及搜索/执行元工具的紧凑 API。完整目录会直接列出每个已暴露的工具——更适合大上下文模型。两者会将二者并列注册,让每个智能体在“控制 Home Assistant”下各自选择。按工具的暴露设置在 HA-MCP 设置面板中管理;详情:{llm_api_docs_url}",
|
||||
"enable_startup_notification": "每次服务器启动时显示通知,指向仅管理员可见的设置界面。关闭则静默启动——连接 URL 仍会出现在 Home Assistant 日志中。",
|
||||
"enable_sidebar_panel": "在侧边栏中显示 HA-MCP 设置面板(仅管理员)。关闭则移除侧边栏条目——服务器选项仍可在此界面中使用。"
|
||||
@@ -96,19 +96,19 @@
|
||||
},
|
||||
"component_outdated": {
|
||||
"title": "请通过 HACS 更新 HA-MCP 自定义组件",
|
||||
"description": "已安装的 ha-mcp 服务器需要 HA-MCP 自定义组件 {required} 或更高版本,但你的版本是 {installed}。请通过 HACS 更新组件(打开 HA-MCP 自定义组件条目;如果尚未显示更新,请使用“Update information”),然后重启 Home Assistant。在此期间服务器仍会继续运行,但在组件更新之前,某些较新的功能可能无法使用。"
|
||||
"description": "已安装的 ha-mcp 服务器需要 HA-MCP 自定义组件 {required} 或更高版本,但您的版本是 {installed}。请通过 HACS 更新组件(打开 HA-MCP 自定义组件条目;如果尚未显示更新,请使用“Update information”),然后重启 Home Assistant。在此期间服务器仍会继续运行,但在组件更新之前,某些较新的功能可能无法使用。"
|
||||
},
|
||||
"server_update_held": {
|
||||
"title": "HA-MCP 服务器更新正在等待组件更新",
|
||||
"description": "ha-mcp 服务器 {latest} 已可用,但该发行版同时更新了 HA-MCP 自定义组件(更新至 {shipped};你正在运行 {running})。为避免启动一个当前组件从未测试过的服务器版本,服务器自动更新将暂缓,直到组件完成更新。\n\n请通过 HACS 更新组件(打开 HA-MCP 自定义组件条目;如果尚未显示更新,请使用“Update information”),然后重启 Home Assistant——之后服务器更新会自动安装。若仍要立即安装服务器更新,请在 HA-MCP 服务器更新实体上点击“安装”。"
|
||||
"description": "ha-mcp 服务器 {latest} 已可用,但该发行版同时更新了 HA-MCP 自定义组件(更新至 {shipped};您正在运行 {running})。为避免启动一个当前组件从未测试过的服务器版本,服务器自动更新将暂缓,直到组件完成更新。\n\n请通过 HACS 更新组件(打开 HA-MCP 自定义组件条目;如果尚未显示更新,请使用“Update information”),然后重启 Home Assistant——之后服务器更新会自动安装。若仍要立即安装服务器更新,请在 HA-MCP 服务器更新实体上点击“安装”。"
|
||||
},
|
||||
"legacy_hacs_source": {
|
||||
"title": "组件安装自旧版仓库",
|
||||
"description": "HACS 为此组件跟踪的是 ha-mcp 服务器主仓库,因此 HACS 在此显示的是服务器的版本号(7.x)和服务器的发行说明,而不是组件自身的(1.x)。更新仍然可用,但会一直被这样错误标注。修复方法:从 HACS 中移除此仓库(你的集成设置和配置项会保留),将 homeassistant-ai/ha-mcp-integration 添加为自定义仓库,从中重新安装组件,然后重启 Home Assistant。"
|
||||
"description": "HACS 为此组件跟踪的是 ha-mcp 服务器主仓库,因此 HACS 在此显示的是服务器的版本号(7.x)和服务器的发行说明,而不是组件自身的(1.x)。更新仍然可用,但会一直被这样错误标注。修复方法:从 HACS 中移除此仓库(您的集成设置和配置项会保留),将 homeassistant-ai/ha-mcp-integration 添加为自定义仓库,从中重新安装组件,然后重启 Home Assistant。"
|
||||
},
|
||||
"legacy_oauth_restart": {
|
||||
"title": "请重启 Home Assistant 以应用旧版 OAuth 更改",
|
||||
"description": "旧版 OAuth 身份验证模式会注册自己的 /authorize 和 /token 网页端点,而 Home Assistant 只有在完全重启时才能绑定或释放它们——无论你是刚刚开启该模式、关闭它,还是更改了它的 Client ID/Secret。请重启 Home Assistant(设置 - 系统 - 重启)以应用更改;在此之前仍保持先前的行为。"
|
||||
"description": "旧版 OAuth 身份验证模式会注册自己的 /authorize 和 /token 网页端点,而 Home Assistant 只有在完全重启时才能绑定或释放它们——无论您是刚刚开启该模式、关闭它,还是更改了它的 Client ID/Secret。请重启 Home Assistant(设置 - 系统 - 重启)以应用更改;在此之前仍保持先前的行为。"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
|
||||
@@ -5379,8 +5379,14 @@ async def _server_entry_update_prep(
|
||||
# auto-updating. Persisting it verbatim would read as an intentional
|
||||
# override and disable auto-updates. This keeps the no-op check honest: a
|
||||
# frame that normalizes to the stored value is unchanged, not a schedule.
|
||||
# 'clear' (case-insensitive) is the empty string's mangling-proof
|
||||
# alias (see ha_dev_manage_server): recognize it here too so raw WS
|
||||
# callers and older servers cannot persist the literal word as a pip
|
||||
# requirement that fails at install time.
|
||||
pip_spec = msg["pip_spec"]
|
||||
if str(pip_spec).strip() in ("", DEFAULT_PIP_SPEC):
|
||||
if str(pip_spec).strip() in ("", DEFAULT_PIP_SPEC) or (
|
||||
str(pip_spec).strip().lower() == "clear"
|
||||
):
|
||||
pip_spec = ""
|
||||
delta[OPT_PIP_SPEC] = pip_spec
|
||||
applying["pip_spec"] = pip_spec
|
||||
|
||||
Reference in New Issue
Block a user