diff --git a/custom_components/ha_mcp_tools/__pycache__/__init__.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 1eddfd7..0000000 Binary files a/custom_components/ha_mcp_tools/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/custom_components/ha_mcp_tools/__pycache__/config_flow.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/config_flow.cpython-314.pyc deleted file mode 100644 index 107e4d6..0000000 Binary files a/custom_components/ha_mcp_tools/__pycache__/config_flow.cpython-314.pyc and /dev/null differ diff --git a/custom_components/ha_mcp_tools/__pycache__/const.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/const.cpython-314.pyc deleted file mode 100644 index f8b6430..0000000 Binary files a/custom_components/ha_mcp_tools/__pycache__/const.cpython-314.pyc and /dev/null differ diff --git a/custom_components/ha_mcp_tools/__pycache__/coordinator.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/coordinator.cpython-314.pyc deleted file mode 100644 index 093dbc6..0000000 Binary files a/custom_components/ha_mcp_tools/__pycache__/coordinator.cpython-314.pyc and /dev/null differ diff --git a/custom_components/ha_mcp_tools/__pycache__/embedded_entry.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/embedded_entry.cpython-314.pyc deleted file mode 100644 index 7459f0b..0000000 Binary files a/custom_components/ha_mcp_tools/__pycache__/embedded_entry.cpython-314.pyc and /dev/null differ diff --git a/custom_components/ha_mcp_tools/__pycache__/embedded_server.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/embedded_server.cpython-314.pyc deleted file mode 100644 index efe39c5..0000000 Binary files a/custom_components/ha_mcp_tools/__pycache__/embedded_server.cpython-314.pyc and /dev/null differ diff --git a/custom_components/ha_mcp_tools/__pycache__/embedded_setup.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/embedded_setup.cpython-314.pyc deleted file mode 100644 index 4b70b53..0000000 Binary files a/custom_components/ha_mcp_tools/__pycache__/embedded_setup.cpython-314.pyc and /dev/null differ diff --git a/custom_components/ha_mcp_tools/__pycache__/hacs_nudge.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/hacs_nudge.cpython-314.pyc deleted file mode 100644 index 58fe2b4..0000000 Binary files a/custom_components/ha_mcp_tools/__pycache__/hacs_nudge.cpython-314.pyc and /dev/null differ diff --git a/custom_components/ha_mcp_tools/__pycache__/install_source_check.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/install_source_check.cpython-314.pyc deleted file mode 100644 index 1d1daf7..0000000 Binary files a/custom_components/ha_mcp_tools/__pycache__/install_source_check.cpython-314.pyc and /dev/null differ diff --git a/custom_components/ha_mcp_tools/__pycache__/llm_api.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/llm_api.cpython-314.pyc deleted file mode 100644 index 874f78e..0000000 Binary files a/custom_components/ha_mcp_tools/__pycache__/llm_api.cpython-314.pyc and /dev/null differ diff --git a/custom_components/ha_mcp_tools/__pycache__/mcp_webhook.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/mcp_webhook.cpython-314.pyc deleted file mode 100644 index 70ab2ca..0000000 Binary files a/custom_components/ha_mcp_tools/__pycache__/mcp_webhook.cpython-314.pyc and /dev/null differ diff --git a/custom_components/ha_mcp_tools/__pycache__/oauth_autoapprove.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/oauth_autoapprove.cpython-314.pyc deleted file mode 100644 index f7f9397..0000000 Binary files a/custom_components/ha_mcp_tools/__pycache__/oauth_autoapprove.cpython-314.pyc and /dev/null differ diff --git a/custom_components/ha_mcp_tools/__pycache__/oauth_legacy.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/oauth_legacy.cpython-314.pyc deleted file mode 100644 index 6d47d76..0000000 Binary files a/custom_components/ha_mcp_tools/__pycache__/oauth_legacy.cpython-314.pyc and /dev/null differ diff --git a/custom_components/ha_mcp_tools/__pycache__/ui_panel.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/ui_panel.cpython-314.pyc deleted file mode 100644 index 9ee7598..0000000 Binary files a/custom_components/ha_mcp_tools/__pycache__/ui_panel.cpython-314.pyc and /dev/null differ diff --git a/custom_components/ha_mcp_tools/__pycache__/update.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/update.cpython-314.pyc deleted file mode 100644 index 9f4af8f..0000000 Binary files a/custom_components/ha_mcp_tools/__pycache__/update.cpython-314.pyc and /dev/null differ diff --git a/custom_components/ha_mcp_tools/__pycache__/websocket_api.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/websocket_api.cpython-314.pyc deleted file mode 100644 index 1c090e1..0000000 Binary files a/custom_components/ha_mcp_tools/__pycache__/websocket_api.cpython-314.pyc and /dev/null differ diff --git a/custom_components/ha_mcp_tools/__pycache__/yaml_rt.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/yaml_rt.cpython-314.pyc deleted file mode 100644 index d64a8b9..0000000 Binary files a/custom_components/ha_mcp_tools/__pycache__/yaml_rt.cpython-314.pyc and /dev/null differ diff --git a/custom_components/ha_mcp_tools/const.py b/custom_components/ha_mcp_tools/const.py index 9802bad..a648573 100644 --- a/custom_components/ha_mcp_tools/const.py +++ b/custom_components/ha_mcp_tools/const.py @@ -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 diff --git a/custom_components/ha_mcp_tools/embedded_server.py b/custom_components/ha_mcp_tools/embedded_server.py index 116fbed..18db056 100644 --- a/custom_components/ha_mcp_tools/embedded_server.py +++ b/custom_components/ha_mcp_tools/embedded_server.py @@ -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: diff --git a/custom_components/ha_mcp_tools/manifest.json b/custom_components/ha_mcp_tools/manifest.json index d7945a6..9ebba5b 100644 --- a/custom_components/ha_mcp_tools/manifest.json +++ b/custom_components/ha_mcp_tools/manifest.json @@ -22,5 +22,5 @@ "requirements": [ "ruamel.yaml>=0.18.0" ], - "version": "1.3.0" + "version": "1.3.1" } diff --git a/custom_components/ha_mcp_tools/translations/zh-Hans.json b/custom_components/ha_mcp_tools/translations/zh-Hans.json index f50e49c..4fbce8c 100644 --- a/custom_components/ha_mcp_tools/translations/zh-Hans.json +++ b/custom_components/ha_mcp_tools/translations/zh-Hans.json @@ -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": { diff --git a/custom_components/ha_mcp_tools/websocket_api.py b/custom_components/ha_mcp_tools/websocket_api.py index 0665103..e13ed12 100644 --- a/custom_components/ha_mcp_tools/websocket_api.py +++ b/custom_components/ha_mcp_tools/websocket_api.py @@ -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 diff --git a/zigbee2mqtt/state.json b/zigbee2mqtt/state.json index a3fae79..0f970dd 100644 --- a/zigbee2mqtt/state.json +++ b/zigbee2mqtt/state.json @@ -4,7 +4,7 @@ "state": "ON", "led_brightness": 100, "countdown_to_turn_off": 0, - "voltage": 120.1, + "voltage": 120.7, "countdown_to_turn_on": 0, "ac_frequency": 60, "power_factor": 0.14, @@ -22,7 +22,7 @@ }, "0xffffb40e0607af27": { "state": "ON", - "voltage": 119.5, + "voltage": 120.3, "ac_frequency": 60, "led_brightness": 100, "countdown_to_turn_off": 0, @@ -30,7 +30,7 @@ "power": 2.4, "current": 0.12, "energy": 26.59, - "power_factor": 0.15, + "power_factor": 0.17, "update": { "state": "idle", "installed_version": 268513381, @@ -45,10 +45,10 @@ "state": "ON", "led_brightness": 100, "countdown_to_turn_off": 0, - "voltage": 119.6, + "voltage": 120.2, "countdown_to_turn_on": 0, - "energy": 48.96, - "power_factor": 0.88, + "energy": 48.98, + "power_factor": 0.2, "ac_frequency": 60, "update": { "state": "idle", @@ -58,8 +58,8 @@ "latest_release_notes": null }, "linkquality": 134, - "power": 81.9, - "current": 0.77, + "power": 0.2, + "current": 0.01, "power_on_behavior": "on" }, "0xb40e060fffe031e3": { @@ -74,12 +74,12 @@ "led_brightness": 100, "countdown_to_turn_off": 0, "countdown_to_turn_on": 0, - "voltage": 118.1, + "voltage": 119, "state": "ON", "ac_frequency": 60, - "energy": 98.71, - "power": 94.7, - "current": 1.26, + "energy": 98.78, + "power": 94, + "current": 0.84, "power_factor": 0.94, "update": { "state": "idle", @@ -95,13 +95,13 @@ "led_brightness": 100, "countdown_to_turn_off": 0, "countdown_to_turn_on": 0, - "voltage": 120, - "energy": 42.44, + "voltage": 120.5, + "energy": 42.47, "state": "ON", - "power": 44.4, - "current": 0.5, + "power": 41.1, + "current": 0.49, "ac_frequency": 60, - "power_factor": 0.72, + "power_factor": 0.75, "update": { "state": "idle", "installed_version": 268513381, @@ -114,7 +114,7 @@ }, "0xffffb40e060895b3": { "state": "ON", - "voltage": 119.8, + "voltage": 120.8, "ac_frequency": 60, "energy": 6.13, "current": 0.01, @@ -136,14 +136,14 @@ "0xffffb40e0608864e": { "led_brightness": 100, "countdown_to_turn_off": 0, - "voltage": 119.8, + "voltage": 120.8, "energy": 17.33, "countdown_to_turn_on": 0, "state": "ON", - "current": 0.03, + "current": 0.02, "ac_frequency": 60, "power": 0.3, - "power_factor": 0.2, + "power_factor": 0.14, "update": { "state": "idle", "installed_version": 268513381, @@ -172,7 +172,7 @@ "0xffffb40e060893d8": { "state": "ON", "led_brightness": 100, - "voltage": 120, + "voltage": 121, "countdown_to_turn_off": 0, "countdown_to_turn_on": 0, "energy": 3.02, @@ -187,12 +187,12 @@ "latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota", "latest_release_notes": null }, - "power_factor": 0.17, - "power": 0.3 + "power_factor": 0.16, + "power": 0 }, "0xa4c1380d0679ffff": { "battery": 100, - "temperature": 29.2, + "temperature": 29.1, "temperature_units": "celsius", "temperature_calibration": 0, "update": {