456 files

This commit is contained in:
Home Assistant Version Control
2026-10-01 05:38:21 +00:00
parent 4192ecbaae
commit 5d6d845ebf
456 changed files with 64951 additions and 3828 deletions
+26 -11
View File
@@ -668,17 +668,22 @@ def _violates_deny_floor(config_dir: Path, normalized: str) -> bool:
return True
if any(seg.lower() in DENY_PATH_SEGMENTS for seg in rel_parts):
return True
# secrets.yaml — by basename of BOTH the requested path AND the resolved
# Denied basenames — matched on BOTH the requested path AND the resolved
# target, so a renamed symlink (``www/notes.txt`` → ``secrets.yaml``) can't
# dodge it and then escape masking (the read handler masks only the literal
# ``secrets.yaml``). The canonical config-root file is the one exception,
# matched EXACTLY (not lowercased) because that is the only path the handler
# masks — a mixed-case ``SECRETS.YAML`` at the root is NOT masked, so it
# must be denied.
# ``secrets.yaml``). The canonical config-root ``secrets.yaml`` is the one
# exception, matched EXACTLY (not lowercased) because that is the only path
# the handler masks — a mixed-case ``SECRETS.YAML`` at the root is NOT
# masked, so it must be denied. The exception also requires the resolved
# target to still be a ``secrets.yaml``, so the one permitted path cannot
# be pointed at another denied file. No other denied basename has an
# exception: ``approval_pin.json`` is out of reach wherever it appears.
if normalized == "secrets.yaml" and resolved.name.lower() == "secrets.yaml":
return False
return (
os.path.basename(normalized).lower() in DENY_READ_BASENAMES
or resolved.name.lower() in DENY_READ_BASENAMES
) and normalized != "secrets.yaml"
)
def _volume_root_for(abs_path: str) -> str | None:
@@ -724,11 +729,11 @@ def _violates_volume_deny_floor(abs_path: str) -> bool:
(issue #1586).
The same floor as the config dir — a ``.storage`` segment anywhere, or a
``secrets.yaml`` basename — applied to both the requested path and its
symlink-resolved target, case-insensitively. Unlike the config dir there is
NO canonical ``secrets.yaml`` exception: volume reads are never masked, so a
``secrets.yaml`` on any volume is always denied. Fails closed on any
resolution error.
denied basename (``const.DENY_READ_BASENAMES``) — applied to both the
requested path and its symlink-resolved target, case-insensitively. Unlike
the config dir there is NO canonical ``secrets.yaml`` exception: volume
reads are never masked, so a ``secrets.yaml`` on any volume is always
denied. Fails closed on any resolution error.
"""
req = PurePosixPath(abs_path)
if any(seg.lower() in DENY_PATH_SEGMENTS for seg in req.parts):
@@ -1320,6 +1325,16 @@ def _list_files_sync(
for item in target_dir.iterdir():
if pattern and not fnmatch.fnmatch(item.name, pattern):
continue
# The deny floor gates the directory being listed, not the entries
# coming back from it, so a name it protects would otherwise be
# enumerable inside an allowed directory -- reporting its size and
# mtime for a file no handler will open. Matched on the entry's own
# name, which is what the floor denies; nothing here resolves a
# symlink, because a link's own name is not the protected one and
# reading through it is refused anyway.
lowered = item.name.lower()
if lowered in DENY_READ_BASENAMES or lowered in DENY_PATH_SEGMENTS:
continue
stat = item.stat()
# Config-relative paths report relative to the config dir; absolute
# HAOS sibling-volume paths (issue #1586) are not under it, so report
+72 -22
View File
@@ -38,6 +38,9 @@ from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from homeassistant.loader import async_get_integration
from packaging.version import InvalidVersion, Version
@@ -539,10 +542,21 @@ class HaMcpServerOptionsFlow(OptionsFlow):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show / apply the server options."""
if user_input is not None:
return self.async_create_entry(title="", data=self._normalize(user_input))
opts = self.config_entry.options
errors: dict[str, str] = {}
if user_input is not None:
errors = self._connect_path_override_errors(user_input)
if not errors:
return self.async_create_entry(
title="", data=self._normalize(user_input)
)
# A validation failure must re-render the values the user just entered.
# Required fields fall back to their stored/default values only when a
# direct unit-test call omits them; optional text fields treat omission
# as an intentional clear, matching Home Assistant's frontend payload.
form_values = opts if user_input is None else {**opts, **user_input}
suggested_values = opts if user_input is None else user_input
schema = vol.Schema(
{
# Authentication mode first, directly under the connect URLs
@@ -551,7 +565,7 @@ class HaMcpServerOptionsFlow(OptionsFlow):
# clients such as Google Gemini Spark and Copilot CLI.
vol.Required(
OPT_WEBHOOK_AUTH,
default=opts.get(OPT_WEBHOOK_AUTH, WEBHOOK_AUTH_NONE),
default=form_values.get(OPT_WEBHOOK_AUTH, WEBHOOK_AUTH_NONE),
): SelectSelector(
SelectSelectorConfig(
options=[
@@ -565,7 +579,7 @@ class HaMcpServerOptionsFlow(OptionsFlow):
),
vol.Required(
OPT_CHANNEL,
default=opts.get(OPT_CHANNEL, DEFAULT_CHANNEL),
default=form_values.get(OPT_CHANNEL, DEFAULT_CHANNEL),
): SelectSelector(
SelectSelectorConfig(
options=[CHANNEL_STABLE, CHANNEL_DEV],
@@ -575,15 +589,15 @@ class HaMcpServerOptionsFlow(OptionsFlow):
),
vol.Required(
OPT_AUTO_UPDATE,
default=bool(opts.get(OPT_AUTO_UPDATE, DEFAULT_AUTO_UPDATE)),
default=bool(form_values.get(OPT_AUTO_UPDATE, DEFAULT_AUTO_UPDATE)),
): bool,
vol.Required(
OPT_SERVER_PORT,
default=opts.get(OPT_SERVER_PORT, DEFAULT_SERVER_PORT),
default=form_values.get(OPT_SERVER_PORT, DEFAULT_SERVER_PORT),
): vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)),
vol.Required(
OPT_BIND_HOST,
default=opts.get(OPT_BIND_HOST, DEFAULT_BIND_HOST),
default=form_values.get(OPT_BIND_HOST, DEFAULT_BIND_HOST),
): SelectSelector(
# Inline labels: hassfest forbids dots in translation
# keys, so the IP-valued options cannot use strings.json
@@ -616,7 +630,9 @@ class HaMcpServerOptionsFlow(OptionsFlow):
# renders an EMPTY field — the help text says "Leave empty",
# and pre-filling DEFAULT_PIP_SPEC would show the STABLE dist
# name even on the dev channel.
description={"suggested_value": opts.get(OPT_PIP_SPEC, "")},
description={
"suggested_value": suggested_values.get(OPT_PIP_SPEC, "")
},
): str,
vol.Optional(
OPT_SERVER_URL,
@@ -626,28 +642,34 @@ class HaMcpServerOptionsFlow(OptionsFlow):
# constant as an explicit override, which would pin the
# scheme/port even after issue #1890's SSL/port-aware
# loopback derivation.
description={"suggested_value": opts.get(OPT_SERVER_URL, "")},
description={
"suggested_value": suggested_values.get(OPT_SERVER_URL, "")
},
): str,
vol.Required(
OPT_ENABLE_WEBHOOK,
default=bool(opts.get(OPT_ENABLE_WEBHOOK, True)),
default=bool(form_values.get(OPT_ENABLE_WEBHOOK, True)),
): bool,
vol.Required(
OPT_ENABLE_STARTUP_NOTIFICATION,
default=bool(opts.get(OPT_ENABLE_STARTUP_NOTIFICATION, True)),
default=bool(
form_values.get(OPT_ENABLE_STARTUP_NOTIFICATION, True)
),
): bool,
vol.Required(
OPT_ENABLE_SIDEBAR_PANEL,
default=bool(opts.get(OPT_ENABLE_SIDEBAR_PANEL, True)),
default=bool(form_values.get(OPT_ENABLE_SIDEBAR_PANEL, True)),
): bool,
vol.Required(
OPT_ENABLE_LLM_API,
default=bool(opts.get(OPT_ENABLE_LLM_API, DEFAULT_ENABLE_LLM_API)),
default=bool(
form_values.get(OPT_ENABLE_LLM_API, DEFAULT_ENABLE_LLM_API)
),
): bool,
vol.Required(
OPT_LLM_API_EXPOSURE,
default=str(
opts.get(OPT_LLM_API_EXPOSURE, DEFAULT_LLM_API_EXPOSURE)
form_values.get(OPT_LLM_API_EXPOSURE, DEFAULT_LLM_API_EXPOSURE)
),
): SelectSelector(
SelectSelectorConfig(
@@ -660,20 +682,26 @@ class HaMcpServerOptionsFlow(OptionsFlow):
# empty submit — see the OPT_PIP_SPEC note above.
vol.Optional(
OPT_EXTERNAL_URL,
description={"suggested_value": opts.get(OPT_EXTERNAL_URL, "")},
description={
"suggested_value": suggested_values.get(OPT_EXTERNAL_URL, "")
},
): str,
vol.Optional(
OPT_WEBHOOK_ID_OVERRIDE,
description={
"suggested_value": opts.get(OPT_WEBHOOK_ID_OVERRIDE, "")
"suggested_value": suggested_values.get(
OPT_WEBHOOK_ID_OVERRIDE, ""
)
},
): str,
vol.Optional(
OPT_SECRET_PATH_OVERRIDE,
description={
"suggested_value": opts.get(OPT_SECRET_PATH_OVERRIDE, "")
"suggested_value": suggested_values.get(
OPT_SECRET_PATH_OVERRIDE, ""
)
},
): str,
): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)),
vol.Optional(
OPT_REGENERATE_SECRETS,
default=False,
@@ -683,14 +711,18 @@ class HaMcpServerOptionsFlow(OptionsFlow):
# Empty = auto-generate/keep the current value.
vol.Optional(
OPT_OAUTH_CLIENT_ID,
description={"suggested_value": opts.get(OPT_OAUTH_CLIENT_ID, "")},
description={
"suggested_value": suggested_values.get(OPT_OAUTH_CLIENT_ID, "")
},
): str,
vol.Optional(
OPT_OAUTH_CLIENT_SECRET,
description={
"suggested_value": opts.get(OPT_OAUTH_CLIENT_SECRET, "")
"suggested_value": suggested_values.get(
OPT_OAUTH_CLIENT_SECRET, ""
)
},
): str,
): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)),
vol.Optional(
OPT_OAUTH_REGENERATE,
default=False,
@@ -720,6 +752,7 @@ class HaMcpServerOptionsFlow(OptionsFlow):
return self.async_show_form(
step_id="init",
data_schema=schema,
errors=errors,
description_placeholders={
"versions": versions,
"connect_url": await self._connect_url_hint(common),
@@ -729,6 +762,23 @@ class HaMcpServerOptionsFlow(OptionsFlow):
},
)
@staticmethod
def _connect_path_override_errors(
user_input: Mapping[str, Any],
) -> dict[str, str]:
"""Return field errors for values that cannot be embedded in a URL path."""
errors: dict[str, str] = {}
for key in (OPT_SECRET_PATH_OVERRIDE, OPT_WEBHOOK_ID_OVERRIDE):
# Match _normalize's long-standing paste-friendly behavior: outer
# whitespace is discarded before saving, while whitespace inside
# the value remains invalid because it breaks the resulting URL.
value = str(user_input.get(key, "") or "").strip()
if any(character in "#?%" or character.isspace() for character in value):
errors[key] = "invalid_connect_path"
elif key == OPT_WEBHOOK_ID_OVERRIDE and "/" in value:
errors[key] = "invalid_webhook_id"
return errors
@staticmethod
def _normalize(user_input: dict[str, Any]) -> dict[str, Any]:
"""Normalize the submitted options before they are persisted.
+16 -2
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 = "2.2.0"
COMPONENT_VERSION = "2.2.1"
# Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value
# means "tools" so the pre-existing services entry keeps working across the
@@ -74,11 +74,25 @@ ALLOWED_WRITE_DIRS = ["www", "themes", "custom_templates", "dashboards"]
# leak secrets and hand out the key to this component's own auth gate.
DENY_PATH_SEGMENTS = frozenset({".storage"})
# Basenames the floor denies wherever they appear. Despite the historical
# name, this set is enforced on every read, write and delete, and the file
# lister drops an entry carrying one of these names from its results, so a
# name here is neither openable nor enumerable through the component.
#
# secrets.yaml is reachable ONLY as the canonical config-root file, where the
# read handler masks its values. Any OTHER secrets.yaml surfaced via a custom
# dir would be returned UNMASKED (masking keys off the literal root path), so
# the floor blocks the basename everywhere except that one canonical location.
DENY_READ_BASENAMES = frozenset({"secrets.yaml"})
#
# approval_pin.json holds the digest of the PIN that authorises an approve or
# deny arriving on the event bus (issue #2502). It lives in ha-mcp's own data
# directory, which on an embedded install sits under the configuration
# directory, and an extra file path covering that directory grants read AND
# write: a tool could copy the digest to attack it offline, or simply replace
# it with the digest of a PIN of its own and decide its own approvals. Denying
# the basename leaves the rest of the data directory reachable, which is what
# the rest of it is for.
DENY_READ_BASENAMES = frozenset({"secrets.yaml", "approval_pin.json"})
# HAOS sibling-volume mounts (issue #1586). These live OUTSIDE the config dir,
# so the config-relative custom-directory allowlist (issue #1567) cannot reach
@@ -67,6 +67,7 @@ from .const import (
DEFAULT_AUTO_UPDATE,
DEFAULT_BIND_HOST,
DEFAULT_CHANNEL,
DEFAULT_ENABLE_LLM_API,
DEFAULT_LOOPBACK_URL,
DEFAULT_PIP_SPEC,
DEFAULT_SERVER_PORT,
@@ -77,6 +78,7 @@ from .const import (
OPT_AUTO_UPDATE,
OPT_BIND_HOST,
OPT_CHANNEL,
OPT_ENABLE_LLM_API,
OPT_PIP_SPEC,
OPT_SERVER_PORT,
OPT_SERVER_URL,
@@ -327,6 +329,9 @@ class EmbeddedServerManager:
self._auto_update: bool = bool(
options.get(OPT_AUTO_UPDATE, DEFAULT_AUTO_UPDATE)
)
self._llm_api_enabled: bool = bool(
options.get(OPT_ENABLE_LLM_API, DEFAULT_ENABLE_LLM_API)
)
# Initial spec without the installed-version read (that would block the
# event loop). For an auto-update-off channel this is the bare dist here;
# _async_ensure_package re-resolves it with the executor-read version
@@ -1533,10 +1538,13 @@ class EmbeddedServerManager:
SSL-enabled instance just because the server is missing an unrelated,
newer parameter.
"""
kwargs: dict[str, Any] = {"config_dir": self._hass_config_dir}
kwargs: dict[str, Any] = {
"config_dir": self._hass_config_dir,
"llm_api_enabled": self._llm_api_enabled,
}
if self._loopback_verify_ssl is not None:
kwargs["verify_ssl"] = self._loopback_verify_ssl
for optional in ("config_dir", "verify_ssl", None):
for optional in ("llm_api_enabled", "config_dir", "verify_ssl", None):
try:
hamcp_config.set_embedded_connection(
self._server_url, access_token, **kwargs
@@ -1556,6 +1564,11 @@ class EmbeddedServerManager:
"the embedded connection; blueprint reads route through "
"the component until the server package updates"
)
elif optional == "llm_api_enabled":
_LOGGER.debug(
"Installed ha-mcp server does not accept the LLM API "
"metadata flag; retaining its legacy tools/list metadata"
)
else:
_LOGGER.warning(
"Installed ha-mcp server does not accept verify_ssl for "
+1 -1
View File
@@ -24,5 +24,5 @@
"ruamel.yaml>=0.18.0",
"voluptuous-openapi>=0.4.1"
],
"version": "2.2.0"
"version": "2.2.1"
}
@@ -24,6 +24,10 @@
}
},
"options": {
"error": {
"invalid_connect_path": "Must not contain #, ?, %, or whitespace.",
"invalid_webhook_id": "Webhook IDs must not contain a slash (/)."
},
"step": {
"tools_info": {
"title": "HA-MCP File & YAML Tools",
@@ -0,0 +1,172 @@
{
"config": {
"step": {
"user": {
"title": "HA-MCP fərdi komponenti",
"description": "Əlavə etmək istədiyinizi seçin. **HA-MCP Server** tam ha-mcp serverini Home Assistant daxilində işlədir və Home Assistant webhook-u vasitəsilə ona giriş imkanı verir - əksər istifadəçilərin istədiyi quraşdırma budur. Bu, müstəqil serverdir və bütün digər quraşdırma üsullarını tam əvəz edir: tətbiq (əlavə), Docker, uvx/PyPI, stdio; onu işlədirsinizsə, digərlərindən birini eyni vaxtda işlətməyin. **HA-MCP File & YAML Tools** iki vəzifəni yerinə yetirir. ha-mcp serveriniz Home Assistant xaricində işləyirsə - tətbiq, Docker, uvx/PyPI, stdio - bu qeyd həmin serverə xaricdən istifadə edə bilmədiyi, proses daxilindəki komponent imkanlarını da verir (proses daxilində sürətli axtarış, konfiqurasiya qeydinin metaməlumatları, reyestrdən oxuma). Buna görə onu hər bir xarici quraşdırmaya əlavə etmək faydalıdır. Həmçinin ha-mcp-nin istəyə bağlı fayl/YAML alətlərinin istifadə etdiyi imtiyazlı fayl və YAML redaktə xidmətlərinə girişi tənzimləyir. Əvəzində HA-MCP Server qeydini əlavə etsəniz, həmin qeyd bu ortaq imkanları artıq təmin edir və ikinci qeyd yalnız istəyə bağlı fayl/YAML alətlərinə girişi təmin etmək üçün lazım olur. O, bütün server növləri ilə işləyir və sonradan istənilən vaxt əlavə edilə bilər.",
"menu_options": {
"server": "HA-MCP Server (tövsiyə olunur)",
"tools": "HA-MCP File & YAML Tools (istəyə bağlı)"
}
},
"tools": {
"title": "HA-MCP File & YAML Tools",
"description": "İmtiyazlı fayl və YAML konfiqurasiya xidmətlərini quraşdırır. Bu xidmətlərə giriş ha-mcp-nin istəyə bağlı fayl/YAML redaktə alətləri ilə məhdudlaşır (funksiya bayraqları, standart halda deaktivdir) - bu, proses daxilində işləyən HA-MCP Server daxil olmaqla bütün server növlərinə aiddir. ha-mcp serveriniz Home Assistant xaricində işləyirsə, bu qeyd ona xaricdən istifadə edə bilmədiyi, proses daxilindəki komponent imkanlarını da verir (proses daxilində sürətli axtarış, konfiqurasiya qeydinin metaməlumatları, reyestrdən oxuma). Fayl/YAML alətləri deaktiv olduqda belə bu imkanlar faydalıdır. Bu qeydi istənilən vaxt əlavə edə və ya çıxara bilərsiniz."
},
"server": {
"title": "HA-MCP Server",
"description": "Tam ha-mcp serverini Home Assistant daxilində işlədir və Home Assistant webhook-u vasitəsilə ona uzaqdan giriş imkanı verir (Nabu Casa və ya istənilən əks proksi vasitəsilə əlçatandır). Bu, müstəqil serverdir və tətbiq (əlavə), Docker, uvx/PyPI və stdio quraşdırma üsullarını tam əvəz edir - onu başqa ha-mcp serveri ilə eyni vaxtda işlətməyin. Başlatmaq üçün **Göndər** düyməsini seçin; portu, şəbəkə ünvanına bağlanmanı və doğrulamanı daha sonra inteqrasiya parametrlərində dəyişə bilərsiniz."
}
},
"abort": {
"already_configured": "Bu qeyd artıq quraşdırılıb.",
"unsupported_home_assistant": "Proses daxilində işləyən HA-MCP Server üçün Home Assistant versiyası {required} və ya daha yeni olmalıdır, lakin bu quraşdırmada {installed} versiyası işləyir. Bu komponentin HA-MCP File & YAML Tools qeydindən tətbiq (əlavə) və ya Docker konteyneri kimi işləyən xarici ha-mcp serveri ilə yenə də istifadə edə bilərsiniz."
}
},
"options": {
"error": {
"invalid_connect_path": "#, ?, %, və ya boşluq simvolları ola bilməz.",
"invalid_webhook_id": "Webhook ID-ləri əyri xətt (/) ehtiva etməməlidir."
},
"step": {
"tools_info": {
"title": "HA-MCP File & YAML Tools",
"description": "ha-mcp-nin istəyə bağlı fayl/YAML alətlərinin istifadə etdiyi imtiyazlı fayl və YAML redaktə xidmətlərini konfiqurasiya edin. Bu parametrlər ha-mcp serverinin öz parametrlər interfeysində də görünür və yenidən başlatmadan dərhal tətbiq olunur. Ləğv edilə bilməyən qadağa həssas yolları (məsələn, .storage) və etimad sərhədini müəyyən edən açarları (homeassistant, http, frontend, lovelace) bloklamağa davam edir.",
"data": {
"allowed_dirs": "Əlavə fayl yolları",
"extra_yaml_keys": "Yazmağa icazə verilən əlavə YAML açarları"
},
"data_description": {
"allowed_dirs": "Konfiqurasiya qovluğuna nəzərən yollar, o cümlədən sensor.yaml kimi dəqiq fayl adları və ya /share, /media, /ssl və /backup daxilindəki yollar. Hər qeyd həmin yol və onun daxilindəki hər şey üçün oxuma və yazma icazəsi verir; hər yolu ayrıca siyahı elementi kimi daxil edin. Qeydlər simvolbəsimvol uyğunlaşdırılır; * kimi əvəzedici simvollar dəstəklənmir. Qovluq sərhədindən kənara çıxan və digər icazə verilməyən yollar nəzərə alınmır.",
"extra_yaml_keys": "Bu quraşdırmada əsasən YAML istifadə edən inteqrasiyalar (məsələn, alert2) üçün ha_config_set_yaml alətinin daxili açarlardan əlavə yaza biləcəyi ən üst səviyyəli açarlar. Etimad sərhədini müəyyən edən açarlar nəzərə alınmır."
}
},
"init": {
"title": "HA-MCP Server",
"description": "HA-MCP serverini konfiqurasiya edin. {panel_hint}Buradakı dəyişikliklər yadda saxlanıldıqda tətbiq olunur.\n\n{versions}\n\n{connect_url}\n\n{oauth_creds}",
"data": {
"channel": "Buraxılış kanalı",
"auto_update": "Avtomatik server yeniləmələri",
"server_port": "MCP serverinin dinləmə portu",
"bind_host": "Şəbəkə üzərindən giriş",
"webhook_auth": "Doğrulama rejimi",
"oauth_client_id_override": "Köhnə OAuth: fərdi müştəri ID-si (istəyə bağlı)",
"oauth_client_secret_override": "Köhnə OAuth: fərdi müştəri gizli açarı (istəyə bağlı)",
"oauth_regenerate": "Köhnə OAuth: müştəri ID-sini/gizli açarını indi yenidən yarat",
"pip_spec": "Proqramçı: ha-mcp paketini əvəz etmə",
"server_url": "Home Assistant URL-i (qabaqcıl)",
"external_url": "Xarici URL (istəyə bağlı)",
"webhook_id_override": "Fərdi webhook gizli açarı (istəyə bağlı)",
"secret_path_override": "Birbaşa giriş üçün fərdi yol (istəyə bağlı)",
"regenerate_secrets": "Əlaqə üçün gizli açarları indi yenidən yarat",
"enable_webhook": "Webhook vasitəsilə uzaqdan giriş",
"enable_llm_api": "Söhbət agenti üçün LLM API",
"llm_api_exposure": "Söhbət agentinə təqdim olunan alətlər",
"enable_startup_notification": "Başlanğıc bildirişi",
"enable_sidebar_panel": "Yan paneldəki parametrlər paneli"
},
"data_description": {
"channel": "Sabit kanalı ən son sabit buraxılışı, Hazırlanma kanalı isə ən yeni hazırlanma yığımını quraşdırır. Avtomatik yeniləmələr aktiv olduqda yenidən yükləmə və ya yenidən başlatma zamanı, eləcə də vaxtaşırı yoxlama nəticəsində seçilmiş kanalın ən yeni yığımı quraşdırılır. Aşağıdakı proqramçı üçün paket əvəzləməsi üstün tutulur və avtomatik yeniləmələri söndürür.",
"auto_update": "Aktiv olduqda seçilmiş kanalın ən yeni buraxılışı avtomatik quraşdırılır - yenidən yükləmə və ya yenidən başlatma zamanı və vaxtaşırı yoxlama vasitəsilə. Deaktiv olduqda siz bu seçimi yenidən aktivləşdirənədək server hazırda quraşdırılmış versiyada qalır. Bu, yalnız ha-mcp server paketini idarə edir; HA-MCP fərdi komponentinin öz yeniləmələri əvvəlki kimi HACS vasitəsilə gəlir.",
"server_port": "Bu serverin dinlədiyi port. ha-mcp tətbiqi (əlavə) 9583 portundan istifadə edir, buna görə hər ikisinin eyni vaxtda işləməsi üçün burada standart port 9584 seçilib - tətbiqi (əlavə) işlətmirsinizsə, istənilən boş port uyğundur.",
"bind_host": "MCP serverinin portu ilə birbaşa kimin əlaqə yarada biləcəyini müəyyən edir. Standart seçim tətbiqlə (əlavə) eynidir: serverə lokal şəbəkənizdən giriş mümkündür və gizli yol giriş məlumatı kimi istifadə olunur. Yalnız Home Assistant işləyən kompüterin özündən əlaqələrə icazə vermək üçün yalnız bu kompüterə giriş seçimini seçin - webhook URL-i və yan panel hər iki halda işləyir.",
"webhook_auth": "MCP müştərilərinin webhook URL-ində özlərini necə doğruladığını müəyyən edir. Gizli URL istifadə edildikdə keçidin özü yeganə giriş məlumatıdır - OAuth ondan istifadə edən müştərilər üçün yenə də işləyir, lakin hər giriş avtomatik təsdiqlənir: hesaba daxil olmaq, müştəri ID-si və ya gizli açar tələb olunmur. Home Assistant ilə giriş seçildikdə claude.ai kimi müştərilər Home Assistant administrator hesabı ilə daxil olur (OAuth). Köhnə OAuth istifadə edildikdə bu inteqrasiya öz giriş xidmətini işlədir və tələb edən müştərilərə yapışdırmaq üçün müştəri ID-si və gizli açar verir - onu tam aktivləşdirmək və ya söndürmək üçün Home Assistant-ı yenidən başlatmaq lazım ola bilər.",
"oauth_client_id_override": "Köhnə OAuth üçün avtomatik yaradılmış müştəri ID-sini əvəz edir. Mövcud ID-ni saxlamaq üçün boş buraxın. Yalnız doğrulama rejimi köhnə OAuth olduqda istifadə olunur.",
"oauth_client_secret_override": "Köhnə OAuth üçün avtomatik yaradılmış müştəri gizli açarını əvəz edir. Mövcud açarı saxlamaq üçün boş buraxın. Yalnız doğrulama rejimi köhnə OAuth olduqda istifadə olunur.",
"oauth_regenerate": "Birdəfəlik əməliyyat: köhnə OAuth rejimi üçün yeni müştəri ID-si və gizli açar yaradır. Yalnız düzəliş bildirişinin tələb etdiyi kimi Home Assistant yenidən başladıldıqdan sonra qüvvəyə minir - siz yenidən başladanadək əvvəlki müştəri ID-si və gizli açar işləməyə davam edir, yeniləri isə işləmir. Həmçinin yuxarıdakı iki əvəzləmə sahəsini təmizləyir.",
"pip_spec": "Boş buraxın. Yalnız müəyyən ha-mcp yığımını sınamaq üçündür (məsələn, buraxılışdan əvvəlki konkret versiyanı seçmək üçün); buraxılış kanalını əvəz edir və sahə təmizlənənədək avtomatik yeniləmələri söndürür.",
"server_url": "Proses daxilindəki serverin sizin Home Assistant quraşdırmanızla əlaqə yaratmaq üçün istifadə etdiyi URL (adətən bu quraşdırmanın özü). Onu bu quraşdırmanın port və SSL parametrlərindən müəyyən etmək üçün boş buraxın; yalnız serverə fərqli marşrut lazım olduqda dəyər daxil edin.",
"external_url": "Əsas əlaqə URL-i kimi göstərilir - Home Assistant sizin domeniniz və ya əks proksi arxasında olduqda bundan istifadə edin. Protokol sxemi daxil olmaqla tam əsas ünvanı daxil edin. Ünvan birbaşa Home Assistant-a yönəlməlidir - brauzerdə açıldıqda HA giriş səhifəniz görünməlidir - və :8123 kimi port (və ya hər hansı digər port) göstərilməməlidir, əks halda uzaqdakı MCP müştəriləri onunla əlaqə yarada bilməyəcək. Nabu Casa / lokal ünvanın avtomatik istifadə olunması üçün boş buraxın.",
"webhook_id_override": "Əlaqə URL-indəki (/api/webhook/...) təsadüfi webhook gizli açarını əvəz edir. URL giriş məlumatıdır - uzun və təxmin edilməsi çətin olan dəyərdən istifadə edin. Mövcud dəyəri saxlamaq üçün boş buraxın.",
"secret_path_override": "Server portunda birbaşa giriş üçün istifadə olunan təsadüfi yolu əvəz edir. Eyni qayda tətbiq olunur: yol giriş məlumatıdır. Mövcud yolu saxlamaq üçün boş buraxın.",
"regenerate_secrets": "Birdəfəlik əməliyyat: yeni təsadüfi webhook gizli açarı və birbaşa giriş yolu yaradır, köhnə əlaqə URL-lərini dərhal etibarsız edir. Həmçinin yuxarıdakı iki əvəzləmə sahəsini təmizləyir.",
"enable_webhook": "Yalnız lokal giriş rejimi üçün söndürün: Home Assistant webhook-u ümumiyyətlə qeydiyyata alınmır, buna görə heç nə - Nabu Casa daxil olmaqla - Home Assistant vasitəsilə serverə giriş əldə edə bilmir. Serverin birbaşa giriş portu və yan panel işləməyə davam edir.",
"enable_llm_api": "Bütün alətləri Home Assistant səsli və mətnli söhbət agentlərinə (OpenAI, Google, Ollama, ...) təqdim edin: aktiv olduqda agentlər Home Assistant-ı idarə et bölməsində 'HA-MCP Server' seçib Assist söhbətindən və səslə alətləri idarə edə bilər. Aktivləşdirmə yalnız seçmək imkanı yaradır - agent üçün onu seçənədək heç bir alət təqdim olunmur. İstifadə təlimatı: {llm_api_docs_url}",
"llm_api_exposure": "Söhbət agentlərinə təqdim olunan alətlər toplusunun forması. Alət axtarışı (standart) agentin kontekstini kiçik saxlayır: sancaqlanmış alətlər və axtarış/icra meta-alətləri olan yığcam API. Tam kataloq təqdim olunan hər bir aləti birbaşa siyahıya alır - böyük kontekstli modellər üçün daha uyğundur. Hər ikisi seçimi ikisini də yanaşı qeydiyyata alır ki, hər agent Home Assistant-ı idarə et bölməsində öz seçimini edə bilsin. Hər alətin təqdim olunması HA-MCP parametrlər panelində idarə olunur; təfərrüatlar: {llm_api_docs_url}",
"enable_startup_notification": "Server hər dəfə başladıqda yalnız administratorlar üçün əlçatan olan parametrlər səhifələrinə yönəldən bildiriş göstərin. Bildirişsiz başlatmaq üçün söndürün - əlaqə URL-ləri yenə də Home Assistant jurnalında görünür.",
"enable_sidebar_panel": "HA-MCP parametrlər panelini yan paneldə göstərin (yalnız administratorlar üçün). Yan paneldəki qeydi çıxarmaq üçün söndürün - server parametrləri bu ekranda əlçatan qalır."
}
}
}
},
"issues": {
"server_start_failed": {
"title": "Proses daxilindəki HA-MCP serverini başlatmaq mümkün olmadı",
"description": "Proses daxilindəki HA-MCP serverini Home Assistant daxilində başlatmaq mümkün olmadı:\n\n{detail}\n\nHome Assistant jurnallarını yoxlayın, sonra yenidən cəhd etmək üçün inteqrasiyanı yenidən yükləyin (və ya əsas problemi aradan qaldırıb yenidən yükləyin)."
},
"server_package_install_failed": {
"title": "Proses daxilindəki HA-MCP server paketini quraşdırmaq mümkün olmadı",
"description": "Proses daxilindəki server üçün ha-mcp paketinin quraşdırılması uğursuz oldu:\n\n{detail}\n\nYuxarıda təsvir olunan uyğunluq və ya quraşdırma problemini aradan qaldırın, sonra yenidən cəhd etmək üçün inteqrasiyanı yenidən yükləyin."
},
"component_outdated": {
"title": "HA-MCP fərdi komponentini HACS vasitəsilə yenilə",
"description": "Quraşdırılmış ha-mcp serveri üçün HA-MCP fərdi komponentinin {required} və ya daha yeni versiyası tələb olunur, lakin sizdə {installed} versiyası var. Komponenti HACS vasitəsilə yeniləyin (HA-MCP fərdi komponentinin qeydini açın və hələ yeniləmə görünmürsə, 'Məlumatları yenilə' seçimindən istifadə edin) və Home Assistant-ı yenidən başladın. Bu müddətdə server işləməyə davam edir, lakin komponent yenilənənədək bəzi yeni funksiyalar işləməyə bilər."
},
"server_update_held": {
"title": "HA-MCP server yeniləməsi komponentin yenilənməsini gözləyir",
"description": "ha-mcp serverinin {latest} versiyası mövcuddur, lakin həmin buraxılışda HA-MCP fərdi komponenti də yenilənib ({shipped} versiyasına; sizdə {running} versiyası işləyir). Hazırda işləyən komponentin heç vaxt sınaqdan keçirilmədiyi server versiyasının başladılmaması üçün komponent yenilənənədək avtomatik server yeniləməsi dayandırılıb.\n\nKomponenti HACS vasitəsilə yeniləyin (HA-MCP fərdi komponentinin qeydini açın və hələ yeniləmə görünmürsə, 'Məlumatları yenilə' seçimindən istifadə edin), sonra Home Assistant-ı yenidən başladın - bundan sonra server yeniləməsi avtomatik quraşdırılır. Server yeniləməsini hər halda quraşdırmaq üçün HA-MCP server yeniləmə obyektində Quraşdır düyməsinə basın."
},
"legacy_hacs_source": {
"title": "Komponent köhnə repozitoriyadan quraşdırılıb",
"description": "HACS bu komponent üçün əsas ha-mcp server repozitoriyasını izləyir, buna görə burada komponentin öz versiya nömrələri və buraxılış qeydləri (2.x) əvəzinə serverin versiya nömrələrini və buraxılış qeydlərini göstərir. Yeniləmələr işləməyə davam edir, lakin bu şəkildə yanlış etiketlənir. Düzəltmək üçün bu repozitoriyanı HACS-dən çıxarın (inteqrasiya parametrləriniz və konfiqurasiya qeydləriniz saxlanılır), homeassistant-ai/ha-mcp-integration ünvanını fərdi repozitoriya kimi əlavə edin, komponenti oradan yenidən quraşdırın və Home Assistant-ı yenidən başladın."
},
"legacy_oauth_restart": {
"title": "Köhnə OAuth parametrlərini tətbiq etmək üçün Home Assistant-ı yenidən başlat",
"fix_flow": {
"step": {
"confirm": {
"title": "Home Assistant yenidən başladılsın?",
"description": "Bu formanı göndərdikdə Home Assistant yenidən başladılacaq və köhnə OAuth parametrləri tətbiq olunacaq.\n\nİndi yenidən başlatmaq üçün **Göndər** düyməsinə basın."
}
}
}
}
},
"selector": {
"server_channel": {
"options": {
"stable": "Sabit (tövsiyə olunur)",
"dev": "Hazırlanma (ən son yığım)"
}
},
"server_webhook_auth": {
"options": {
"none": "Gizli webhook URL-i (standart)",
"ha_auth": "Home Assistant ilə daxil ol (OAuth)",
"legacy": "Köhnə OAuth (bu inteqrasiya yapışdırmaq üçün müştəri ID-si/gizli açar verir)"
}
},
"llm_api_exposure": {
"options": {
"tool_search": "Alət axtarışı (yığcam, standart)",
"full": "Tam kataloq",
"both": "Hər ikisi (hər agent üçün seç)"
}
}
},
"entity": {
"update": {
"server_update": {
"name": "Yenilə"
}
}
},
"common": {
"connect_direct_access": "Home Assistant işləyən kompüterdən birbaşa giriş: {url}",
"connect_local_lan": "Lokal/LAN (Şəbəkə üzərindən giriş seçimi \"Local network\" olduqda): {url}",
"connect_remote_url": "Uzaqdan əlaqə URL-i: {url}",
"connect_urls_label": "Əlaqə URL-ləri:",
"connect_urls_pending": "Server başladıqdan sonra əlaqə URL-ləri burada (və Home Assistant jurnalında) görünür.",
"connect_webhook_disabled": "Webhook vasitəsilə uzaqdan giriş deaktivdir (yalnız lokal giriş rejimi).",
"oauth_creds_pending": "Server başladıqdan sonra müştəri ID-si və müştəri gizli açarı burada görünür.",
"oauth_not_serving": "Köhnə OAuth bunları hələ istifadəyə vermir — aktivləşdirmək üçün tələb olunduqda Home Assistant-ı yenidən başladın.",
"oauth_select_legacy_mode": "Müştəri ID-si və müştəri gizli açarı yaratmaq üçün yuxarıdakı doğrulama rejimi seçimində köhnə OAuth seçin və yadda saxlayın.",
"panel_hint": "Alətlərin idarə olunması və server parametrləri üçün [HA-MCP parametrlər panelini](/ha-mcp) açın.",
"tools_module_installed": "Beta/qabaqcıl fayl və YAML alətləri modulu (istəyə bağlı): Quraşdırılıb",
"tools_module_not_installed": "Beta/qabaqcıl fayl və YAML alətləri modulu (istəyə bağlı): Quraşdırılmayıb — əlavə etmək üçün bu inteqrasiyanın səhifəsində \"Add entry\" düyməsinə basın və \"HA-MCP File & YAML Tools\" seçin",
"tools_module_not_loaded": "Beta/qabaqcıl fayl və YAML alətləri modulu (istəyə bağlı): Quraşdırılıb, lakin yüklənməyib — bu inteqrasiyanın səhifəsində \"HA-MCP File & YAML Tools\" qeydini aktivləşdirin və ya yenidən yükləyin",
"version_line": "Komponent {component_version} - ha-mcp serveri {server_version} ({channel} kanalı)",
"version_not_installed": "hələ quraşdırılmayıb",
"version_unknown": "naməlum"
}
}
@@ -24,6 +24,10 @@
}
},
"options": {
"error": {
"invalid_connect_path": "Nesmí obsahovat #, ?, % ani mezery.",
"invalid_webhook_id": "ID webhooků nesmí obsahovat lomítko (/)."
},
"step": {
"tools_info": {
"title": "Nástroje souborů a YAML HA-MCP",
@@ -24,6 +24,10 @@
}
},
"options": {
"error": {
"invalid_connect_path": "Darf kein #, ?, % oder Leerzeichen enthalten.",
"invalid_webhook_id": "Webhook-IDs dürfen keinen Schrägstrich (/) enthalten."
},
"step": {
"tools_info": {
"title": "HA-MCP File & YAML Tools",
@@ -24,6 +24,10 @@
}
},
"options": {
"error": {
"invalid_connect_path": "Must not contain #, ?, %, or whitespace.",
"invalid_webhook_id": "Webhook IDs must not contain a slash (/)."
},
"step": {
"tools_info": {
"title": "HA-MCP File & YAML Tools",
@@ -24,6 +24,10 @@
}
},
"options": {
"error": {
"invalid_connect_path": "Ne devas enhavi #, ?, %, aŭ blankspacon.",
"invalid_webhook_id": "Webhook-identigiloj ne devas enhavi oblikvon (/)."
},
"step": {
"tools_info": {
"title": "HA-MCP Dosieraj & YAML Iloj",
@@ -24,6 +24,10 @@
}
},
"options": {
"error": {
"invalid_connect_path": "No debe contener #, ?, %, ni espacios en blanco.",
"invalid_webhook_id": "Los ID de webhook no deben contener una barra diagonal (/)."
},
"step": {
"tools_info": {
"title": "Herramientas de archivos y YAML de HA-MCP",
@@ -24,6 +24,10 @@
}
},
"options": {
"error": {
"invalid_connect_path": "Ne doit pas contenir de caractères #, ?, % ni d'espaces.",
"invalid_webhook_id": "Les identifiants de webhook ne doivent pas contenir de barre oblique (/)."
},
"step": {
"tools_info": {
"title": "Outils Fichier & YAML HA-MCP",
@@ -24,6 +24,10 @@
}
},
"options": {
"error": {
"invalid_connect_path": "Non deve contenere #, ?, %, o spazi.",
"invalid_webhook_id": "Gli ID dei webhook non devono contenere una barra (/)."
},
"step": {
"tools_info": {
"title": "Strumenti file e YAML di HA-MCP",
@@ -24,6 +24,10 @@
}
},
"options": {
"error": {
"invalid_connect_path": "#, ?, %, 공백을 포함할 수 없습니다.",
"invalid_webhook_id": "웹훅 ID에는 슬래시(/)를 포함할 수 없습니다."
},
"step": {
"tools_info": {
"title": "HA-MCP 파일 및 YAML 도구",
@@ -24,6 +24,10 @@
}
},
"options": {
"error": {
"invalid_connect_path": "Mag geen #, ?, %, of witruimte bevatten.",
"invalid_webhook_id": "Webhook-ID's mogen geen slash (/) bevatten."
},
"step": {
"tools_info": {
"title": "HA-MCP Bestands- & YAML-hulpmiddelen",
@@ -24,6 +24,10 @@
}
},
"options": {
"error": {
"invalid_connect_path": "Nie może zawierać znaków #, ?, % ani białych znaków.",
"invalid_webhook_id": "Identyfikatory webhooków nie mogą zawierać ukośnika (/)."
},
"step": {
"tools_info": {
"title": "Narzędzia plików i YAML HA-MCP",
@@ -24,6 +24,10 @@
}
},
"options": {
"error": {
"invalid_connect_path": "Не должно содержать #, ?, %, или пробелы.",
"invalid_webhook_id": "Идентификаторы вебхуков не должны содержать слеш (/)."
},
"step": {
"tools_info": {
"title": "Файловые инструменты и инструменты YAML HA-MCP",
@@ -24,6 +24,10 @@
}
},
"options": {
"error": {
"invalid_connect_path": "Får inte innehålla #, ?, % eller mellanslag.",
"invalid_webhook_id": "Webhook-ID:n får inte innehålla ett snedstreck (/)."
},
"step": {
"tools_info": {
"title": "HA-MCP Fil- & YAML-verktyg",
@@ -24,6 +24,10 @@
}
},
"options": {
"error": {
"invalid_connect_path": "不能包含 #、?、% 或空格。",
"invalid_webhook_id": "Webhook ID 不能包含斜杠 (/)。"
},
"step": {
"tools_info": {
"title": "HA-MCP 文件与 YAML 工具",
@@ -24,6 +24,10 @@
}
},
"options": {
"error": {
"invalid_connect_path": "不得包含 #、?、% 或空白字元。",
"invalid_webhook_id": "Webhook ID 不得包含斜線 (/)。"
},
"step": {
"tools_info": {
"title": "HA-MCP File & YAML Tools",
+107 -34
View File
@@ -2,10 +2,10 @@
This module registers versioned ``ha_mcp_tools/*`` WebSocket commands that the
ha-mcp server calls in-process (same HA core, no REST/WS round-trips) behind a
capability gate. It registers twenty-three commands. It advertises twenty-seven
capabilities: twenty-two command capabilities plus five additive flags
capability gate. It advertises command capabilities and additive flags
(dashboards_doc_search, device_registry_child_semantics, search_visibility,
search_entity_membership, and search_visibility_allowlist_authorization);
search_entity_membership, search_visibility_allowlist_authorization, and
search_unified);
the info handshake carries no capability entry:
* ``ha_mcp_tools/info`` — the handshake: ``schema_version`` + ``capabilities[]``
@@ -16,6 +16,8 @@ the info handshake carries no capability entry:
* ``ha_mcp_tools/search`` — a unified in-process search over live registries and
states, joined and scored, mirroring today's ``ha_search`` response envelope.
The search_entity_membership flag gates opt-in generic group metadata.
The search_unified flag covers area/floor resolution, state filtering before
pagination, queryless listing, and search windows beyond the advisory limit.
* ``ha_mcp_tools/overview`` — the raw in-process reads the server's
``get_system_overview`` + ``ha_get_overview`` wrapper consume (states,
services, entity/device/area registries, ``hass.config``, persistent
@@ -179,6 +181,13 @@ the info handshake carries no capability entry:
All awaiting work lives in :func:`_bulk_call_service_prep`;
:func:`_do_bulk_call_service` is a pure formatter that reuses the single
``call_service`` guard / transition / diff helpers.
* ``ha_mcp_tools/template_diagnose`` — renders a template in-process and reports
the stage (``compile`` / ``render`` / ``timeout`` / ``none``) and, for a
failure, the error with the template ``line`` and ``source_line`` it points
at, which Core's ``render_template`` drops (#2522). The render is guarded by
Core's own ``async_render_will_timeout`` before the template is rendered on
the loop; see
:mod:`.template_diagnose`.
* ``ha_mcp_tools/config_entries`` — config entries as the ``config_entries/get``
WS shape (``created_at`` / ``modified_at`` / ``entry_id`` / ``domain`` /
@@ -302,6 +311,7 @@ from homeassistant.helpers import (
label_registry as lr,
)
from . import template_diagnose
from .const import (
CHANNEL_DEV,
CHANNEL_STABLE,
@@ -313,6 +323,11 @@ from .const import (
OPT_CHANNEL,
OPT_PIP_SPEC,
)
from .search_locations import (
add_location_metadata,
add_registry_failures,
resolve_search_location,
)
_LOGGER = logging.getLogger(__name__)
@@ -342,6 +357,7 @@ WS_SERVER_ENTRY = f"{WS_API_PREFIX}/server_entry"
WS_SERVER_ENTRY_UPDATE = f"{WS_API_PREFIX}/server_entry_update"
WS_CALL_SERVICE = f"{WS_API_PREFIX}/call_service"
WS_BULK_CALL_SERVICE = f"{WS_API_PREFIX}/bulk_call_service"
WS_TEMPLATE_DIAGNOSE = f"{WS_API_PREFIX}/template_diagnose"
# Wire-format generation of the request/response envelopes. Bumped only on an
# *incompatible* shape change to an existing command; additive fields do not
@@ -354,6 +370,8 @@ SCHEMA_VERSION = 1
# capability key of its own.)
CAPABILITIES: list[str] = [
"search",
# Location/state filters precede pagination; search accepts any result window.
"search_unified",
# A flag on search: gates its additive result_fields request and generic
# is_group/member_entity_ids response fields.
"search_entity_membership",
@@ -417,6 +435,9 @@ CAPABILITIES: list[str] = [
# component route on this; a component that lacks it is never sent a batch
# write and stays on the legacy per-entity path.
"bulk_call_service",
# The server's ha_eval_template asks for a failed template's line only when
# this is advertised; without it the error is returned as Core reported it.
"template_diagnose",
]
# The registry kinds ``ha_mcp_tools/registries`` can serve. The WS schema gates
@@ -648,6 +669,7 @@ def _command_specs() -> list[tuple[dict[Any, Any], Any, Any]]:
_do_bulk_call_service,
_bulk_call_service_prep,
),
(_template_diagnose_schema(), _do_template_diagnose, _template_diagnose_prep),
]
@@ -674,6 +696,45 @@ def _info_schema() -> dict[Any, Any]:
return {vol.Required("type"): WS_INFO}
# The same bound as ha_eval_template's timeout parameter; it keeps one diagnosis
# from holding a render thread longer than a caller could usefully wait.
TEMPLATE_DIAGNOSE_MAX_TIMEOUT = 60.0
def _template_diagnose_schema() -> dict[Any, Any]:
return {
vol.Required("type"): WS_TEMPLATE_DIAGNOSE,
vol.Required("template"): str,
vol.Optional("variables"): dict,
vol.Optional("strict", default=False): bool,
vol.Optional("timeout", default=3.0): vol.All(
vol.Coerce(float), vol.Range(min=0.1, max=TEMPLATE_DIAGNOSE_MAX_TIMEOUT)
),
}
async def _template_diagnose_prep(
hass: HomeAssistant, msg: dict[str, Any]
) -> dict[str, Any]:
"""Async pre-step for ``template_diagnose``: the guarded in-process render."""
return {
"diagnosis": await template_diagnose.async_diagnose(
hass,
msg["template"],
msg.get("variables"),
msg["strict"],
msg["timeout"],
)
}
def _do_template_diagnose(
hass: HomeAssistant, params: dict[str, Any], *, diagnosis: dict[str, Any]
) -> dict[str, Any]:
"""Return the diagnosis :func:`_template_diagnose_prep` produced."""
return diagnosis
# The nine hide dimensions ``VisibilityConfig.to_wire`` emits, split by wire type
# (seven id/name lists, two bool flags), plus the optional
# ``allowlist_authorization`` precedence flag the server adds for a component that
@@ -730,9 +791,7 @@ def _search_schema() -> dict[Any, Any]:
vol.Optional("exact", default=True): bool,
vol.Optional("include_hidden", default=True): bool,
vol.Optional("include_config", default=False): bool,
vol.Optional("limit", default=DEFAULT_LIMIT): vol.All(
int, vol.Range(min=1, max=MAX_RESULTS)
),
vol.Optional("limit", default=DEFAULT_LIMIT): vol.All(int, vol.Range(min=1)),
vol.Optional("offset", default=0): vol.All(int, vol.Range(min=0)),
# Opt-in entity visibility for component search. The component advertises
# ``search_visibility`` and ``search_visibility_allowlist_authorization``;
@@ -1032,6 +1091,9 @@ class _RegistryView:
floor: Any = None
label: Any = None
device: Any = None
_access_failures: set[str] = dataclass_field(
default_factory=set, init=False, repr=False, compare=False
)
# One request-local, conflict-filtered semantic snapshot plus the identities
# removed from it. Visibility filtering consumes the former and its warning
@@ -1132,6 +1194,11 @@ def _do_search(
# load_hidden_set warnings).
visibility_warnings: list[str] = []
hidden: set[str] = set()
location = (
resolve_search_location(view, area_filter)
if area_filter and SEARCH_TYPE_ENTITY in search_types
else None
)
# ``secret_values`` (loaded off-loop by _search_prep) scrubs resolved-!secret
# plaintext from the config-body match corpus: a YAML-loaded automation/script/
@@ -1152,7 +1219,7 @@ def _do_search(
exact=exact,
include_hidden=include_hidden,
domain_filter=domain_filter,
area_filter=area_filter,
area_filter=location.area_ids if location else None,
state_filter=state_filter,
include_membership=membership_requested,
)
@@ -1206,6 +1273,9 @@ def _do_search(
entities = [
_project_entity(r, include_membership=membership_requested) for r in page
]
add_registry_failures(
location, view._access_failures, diagnostics, partial_reasons
)
# --- Config surfaces (automations + scripts + scenes + helpers) ----------
# One combined pagination window, mirroring the server's config branch.
@@ -1279,7 +1349,7 @@ def _do_search(
# ha_search consumer merges these into the response's top-level warnings.
if visibility_warnings:
result["visibility_warnings"] = visibility_warnings
return result
return add_location_metadata(result, location)
def _sort_key(rec: dict[str, Any]) -> str:
@@ -1401,13 +1471,12 @@ def _search_entities(
exact: bool,
include_hidden: bool,
domain_filter: str | None,
area_filter: str | None,
area_filter: set[str] | None,
state_filter: str | None,
include_membership: bool = False,
) -> list[dict[str, Any]]:
"""Score every state against the query over the joined registry view."""
results: list[dict[str, Any]] = []
area_filter_lower = area_filter.lower() if area_filter else None
# Lower the state filter once; the entity state is lowered per record so the
# compare is case-insensitive (e.g. an input_select holding "Vacation"
# matches state_filter="vacation").
@@ -1423,9 +1492,7 @@ def _search_entities(
and (rec["state"] or "").lower() != state_filter_lower
):
continue
if area_filter_lower is not None and not _entity_matches_area(
rec, area_filter_lower
):
if area_filter is not None and rec["_area_id"] not in area_filter:
continue
if match_all:
@@ -1628,14 +1695,6 @@ def _entity_record(
}
def _entity_matches_area(rec: dict[str, Any], area_filter_lower: str) -> bool:
area_id = rec.get("_area_id")
if area_id and str(area_id).lower() == area_filter_lower:
return True
area_name = rec.get("area")
return bool(area_name and str(area_name).lower() == area_filter_lower)
def _project_entity(
rec: dict[str, Any], *, include_membership: bool = False
) -> dict[str, Any]:
@@ -2351,16 +2410,22 @@ def _iter_config_entries(hass: HomeAssistant) -> list[Any]:
def _reg_entity(view: _RegistryView, entity_id: str) -> Any:
return _call_lookup(view.entity, "async_get", entity_id)
return _call_lookup(view, "entity", "async_get", entity_id)
def _device(view: _RegistryView, device_id: str | None) -> Any:
if not device_id:
return None
return _call_lookup(view.device, "async_get", device_id)
return _call_lookup(view, "device", "async_get", device_id)
def _device_collection_values(collection: Any, *, collection_name: str) -> list[Any]:
def _device_collection_values(
view: _RegistryView,
collection: Any,
*,
collection_name: str,
mapping_like: bool = False,
) -> list[Any]:
"""Enumerate a Core device collection across old and 2026.9 shapes.
Before Core 2026.9 ``registry.devices`` was a mapping-like container. Core
@@ -2371,10 +2436,11 @@ def _device_collection_values(collection: Any, *, collection_name: str) -> list[
"""
if collection is None:
return []
if isinstance(collection, Mapping):
if mapping_like or isinstance(collection, Mapping):
try:
return list(collection.values())
except Exception: # pragma: no cover - defensive
view._access_failures.add("device")
_LOGGER.warning(
"failed to enumerate device registry collection %s",
collection_name,
@@ -2384,6 +2450,7 @@ def _device_collection_values(collection: Any, *, collection_name: str) -> list[
try:
return list(collection)
except Exception: # pragma: no cover - defensive
view._access_failures.add("device")
_LOGGER.warning(
"failed to enumerate device registry collection %s",
collection_name,
@@ -2411,16 +2478,20 @@ def _unambiguous_device_entries(view: _RegistryView) -> dict[str, Any]:
main_collection = getattr(reg, "devices", None)
if hasattr(reg, "child_devices"):
candidates = _device_collection_values(
main_collection, collection_name="devices"
view, main_collection, collection_name="devices"
)
candidates.extend(
_device_collection_values(
getattr(reg, "child_devices", None), collection_name="child_devices"
view,
getattr(reg, "child_devices", None),
collection_name="child_devices",
)
)
else:
# The pre-2026.9 container is mapping-like and iterates ids, not entries.
candidates = _mapping_values(main_collection)
candidates = _device_collection_values(
view, main_collection, collection_name="devices", mapping_like=True
)
by_id: dict[str, Any] = {}
conflicts: set[str] = set()
@@ -2527,7 +2598,7 @@ def _effective_device_area_id(view: _RegistryView, device: Any) -> str | None:
def _area_name(view: _RegistryView, area_id: str | None) -> str | None:
if not area_id:
return None
area = _call_lookup(view.area, "async_get_area", area_id)
area = _call_lookup(view, "area", "async_get_area", area_id)
name = getattr(area, "name", None) if area is not None else None
return str(name) if name else None
@@ -2535,11 +2606,11 @@ def _area_name(view: _RegistryView, area_id: str | None) -> str | None:
def _floor_name_for_area(view: _RegistryView, area_id: str | None) -> str | None:
if not area_id:
return None
area = _call_lookup(view.area, "async_get_area", area_id)
area = _call_lookup(view, "area", "async_get_area", area_id)
floor_id = getattr(area, "floor_id", None) if area is not None else None
if not floor_id:
return None
floor = _call_lookup(view.floor, "async_get_floor", floor_id)
floor = _call_lookup(view, "floor", "async_get_floor", floor_id)
name = getattr(floor, "name", None) if floor is not None else None
return str(name) if name else None
@@ -2547,13 +2618,14 @@ def _floor_name_for_area(view: _RegistryView, area_id: str | None) -> str | None
def _label_names(view: _RegistryView, label_ids: Any) -> list[str]:
names: list[str] = []
for label_id in sorted(label_ids or []):
label = _call_lookup(view.label, "async_get_label", label_id)
label = _call_lookup(view, "label", "async_get_label", label_id)
name = getattr(label, "name", None) if label is not None else None
names.append(str(name) if name else str(label_id))
return names
def _call_lookup(registry: Any, method: str, key: str) -> Any:
def _call_lookup(view: _RegistryView, registry_name: str, method: str, key: str) -> Any:
registry = getattr(view, registry_name)
if registry is None:
return None
getter = getattr(registry, method, None)
@@ -2561,7 +2633,8 @@ def _call_lookup(registry: Any, method: str, key: str) -> Any:
return None
try:
return getter(key)
except Exception: # pragma: no cover - defensive
except Exception:
view._access_failures.add(registry_name)
return None