This commit is contained in:
Home Assistant Version Control
2026-08-05 23:27:04 +00:00
parent 32a0b6c7f3
commit be7e50610a
23 changed files with 229 additions and 77 deletions
+1 -1
View File
@@ -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
+182 -36
View File
@@ -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:
+1 -1
View File
@@ -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
+25 -25
View File
@@ -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": {