39 files
This commit is contained in:
@@ -45,6 +45,10 @@ _MAX_ASSETS = 20_000
|
||||
_URL_BATCH = 25
|
||||
|
||||
_BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
# Characters allowed in a share token. Legacy shared-streams tokens are base62;
|
||||
# the newer CloudKit short GUIDs use a URL-safe base64 alphabet, so they may
|
||||
# also contain ``-`` and ``_`` (e.g. ``045YeI20-8u3X31bBPD5z9B_A``).
|
||||
_TOKEN_CHARS = frozenset(_BASE62 + "-_")
|
||||
|
||||
# Which iCloud backend serves an album. These string values match the
|
||||
# ``ICLOUD_BACKEND_*`` constants in ``const.py``; they are duplicated here to
|
||||
@@ -117,9 +121,10 @@ def parse_share_link(url: str) -> str | None:
|
||||
# Drop any leftover query string.
|
||||
text = text.split("?", 1)[0]
|
||||
token = text.strip()
|
||||
# Tokens are base62. Legacy tokens start with an uppercase letter; the new
|
||||
# CloudKit short GUIDs may start with a digit, so no leading-char check.
|
||||
if token and all(ch in _BASE62 for ch in token):
|
||||
# Legacy tokens are base62 and start with an uppercase letter; the newer
|
||||
# CloudKit short GUIDs may start with a digit and can contain ``-``/``_``,
|
||||
# so accept the wider URL-safe alphabet without a leading-char check.
|
||||
if token and all(ch in _TOKEN_CHARS for ch in token):
|
||||
return token
|
||||
return None
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
|
||||
"requirements": ["Pillow"],
|
||||
"version": "1.7.0"
|
||||
"version": "1.7.1"
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* tap_action: none # none | more-info
|
||||
*/
|
||||
|
||||
const VERSION = "1.7.0";
|
||||
const VERSION = "1.7.1";
|
||||
|
||||
const ANIMATED_TRANSITIONS = [
|
||||
"fade",
|
||||
|
||||
@@ -58,6 +58,7 @@ from .const import (
|
||||
TOOLS_ENTRY_LEGACY_TITLE,
|
||||
TOOLS_ENTRY_TITLE,
|
||||
YAML_KEY_DEFAULT_POST_ACTION,
|
||||
YAML_KEY_DENYLIST,
|
||||
YAML_KEY_POST_ACTIONS,
|
||||
)
|
||||
from .websocket_api import async_register_commands
|
||||
@@ -80,6 +81,8 @@ SERVICE_EDIT_YAML_CONFIG = "edit_yaml_config"
|
||||
SERVICE_GET_CALLER_TOKEN = "get_caller_token"
|
||||
SERVICE_GET_ALLOWED_PATHS = "get_allowed_paths"
|
||||
SERVICE_SET_ALLOWED_PATHS = "set_allowed_paths"
|
||||
SERVICE_GET_EXTRA_YAML_KEYS = "get_extra_yaml_keys"
|
||||
SERVICE_SET_EXTRA_YAML_KEYS = "set_extra_yaml_keys"
|
||||
# Read-only access to pre-#1579 YAML backups in .ha_mcp_tools_backups/, so the
|
||||
# shared edits-backup interface can list/view/diff/restore them (#1579). These
|
||||
# historical artifacts predate the fold into the shared store; new writes no
|
||||
@@ -105,6 +108,18 @@ _ALLOWED_PATHS_STORAGE_KEY = f"{DOMAIN}_allowed_paths"
|
||||
_ALLOWED_PATHS_STORAGE_VERSION = 1
|
||||
_HASS_DATA_ALLOWED_PATHS_KEY = "allowed_paths"
|
||||
|
||||
# User-configurable extra YAML write keys (#1887). Same store-per-concern
|
||||
# rationale as the allowed paths above: a separate Store, loaded into hass.data
|
||||
# at setup and updated in place by set_extra_yaml_keys so enforcement picks up
|
||||
# changes with no HA restart. This is the component-owned half of the setting;
|
||||
# the ha-mcp server also carries its own HA_MCP_EXTRA_YAML_KEYS, and the
|
||||
# effective write allowlist is the union of the two (the server reads this store
|
||||
# via get_extra_yaml_keys). YAML_KEY_DENYLIST members are stripped on the way in
|
||||
# and re-checked at enforcement, so the store can never widen the deny floor.
|
||||
_EXTRA_YAML_KEYS_STORAGE_KEY = f"{DOMAIN}_extra_yaml_keys"
|
||||
_EXTRA_YAML_KEYS_STORAGE_VERSION = 1
|
||||
_HASS_DATA_EXTRA_YAML_KEYS_KEY = "extra_yaml_keys"
|
||||
|
||||
# Service schemas
|
||||
SERVICE_EDIT_YAML_CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -130,6 +145,15 @@ SERVICE_EDIT_YAML_CONFIG_SCHEMA = vol.Schema(
|
||||
vol.Optional("disabled_packages_keys", default=list): vol.All(
|
||||
cv.ensure_list, [cv.string]
|
||||
),
|
||||
# Caller-provided extra top-level keys the operator has opted into
|
||||
# on top of ALLOWED_YAML_KEYS (#1887). Additive only: the handler
|
||||
# filters YAML_KEY_DENYLIST out before use, so a caller cannot
|
||||
# unlock a trust-boundary key by sending it here. Empty list (the
|
||||
# default) means the built-in allowlist applies unchanged, which
|
||||
# is also what a caller that predates this field produces.
|
||||
vol.Optional("extra_allowed_keys", default=list): vol.All(
|
||||
cv.ensure_list, [cv.string]
|
||||
),
|
||||
# Two-step preview/confirm flow (#1720). Both optional with
|
||||
# old-behavior defaults so a pre-confirm-flow server (which never
|
||||
# sends them) still gets an immediate write.
|
||||
@@ -208,6 +232,24 @@ SERVICE_SET_ALLOWED_PATHS_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
# get_extra_yaml_keys / set_extra_yaml_keys back the component's own options
|
||||
# flow and the ha-mcp settings UI (#1887). Both are caller-token + admin gated,
|
||||
# matching the allowed-paths pair. set_extra_yaml_keys receives the FULL
|
||||
# replacement list; the handler strips whitespace/empties and drops any
|
||||
# YAML_KEY_DENYLIST member, reporting drops in ``rejected``.
|
||||
SERVICE_GET_EXTRA_YAML_KEYS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CALLER_TOKEN_FIELD): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_SET_EXTRA_YAML_KEYS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional("keys", default=list): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CALLER_TOKEN_FIELD): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_LIST_LEGACY_BACKUPS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CALLER_TOKEN_FIELD): cv.string,
|
||||
@@ -335,6 +377,128 @@ async def _save_allowed_paths(hass: HomeAssistant, paths: list[str]) -> None:
|
||||
await store.async_save({"paths": paths})
|
||||
|
||||
|
||||
def _normalize_extra_yaml_keys(raw: Any) -> tuple[list[str], list[Any]]:
|
||||
"""Clean a candidate extra-YAML-keys list into ``(kept, dropped)`` (#1887).
|
||||
|
||||
Mirrors the server's ``parse_extra_yaml_write_keys`` (strip whitespace, drop
|
||||
empties, dedup, sort) and additionally drops any ``YAML_KEY_DENYLIST``
|
||||
member, so the component store can never widen the deny floor. Non-string
|
||||
and blank entries are dropped too. ``dropped`` collects every rejected
|
||||
entry for reporting/logging. Case is preserved: HA's own top-level domain
|
||||
lookup is exact-case, so a mis-cased key never reaches a real integration.
|
||||
"""
|
||||
kept: list[str] = []
|
||||
dropped: list[Any] = []
|
||||
seen: set[str] = set()
|
||||
for entry in raw if isinstance(raw, list) else []:
|
||||
if not isinstance(entry, str):
|
||||
dropped.append(entry)
|
||||
continue
|
||||
key = entry.strip()
|
||||
if not key or key in YAML_KEY_DENYLIST:
|
||||
dropped.append(entry)
|
||||
continue
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
kept.append(key)
|
||||
return sorted(kept), dropped
|
||||
|
||||
|
||||
async def _load_extra_yaml_keys(hass: HomeAssistant) -> list[str]:
|
||||
"""Return the persisted user-configurable extra YAML write keys (#1887).
|
||||
|
||||
Fail-safe like :func:`_load_allowed_paths`: a corrupt/unreadable or
|
||||
hand-edited store never propagates out of ``async_setup_entry``. Every entry
|
||||
is re-validated through :func:`_normalize_extra_yaml_keys`, so a denylisted
|
||||
or malformed key can never load into ``hass.data`` (defense in depth - the
|
||||
deny floor is also re-checked at enforcement). Empty list on first run or a
|
||||
malformed store.
|
||||
"""
|
||||
store: Store = Store(
|
||||
hass, _EXTRA_YAML_KEYS_STORAGE_VERSION, _EXTRA_YAML_KEYS_STORAGE_KEY
|
||||
)
|
||||
try:
|
||||
data = await store.async_load()
|
||||
except Exception:
|
||||
_LOGGER.warning(
|
||||
"ha_mcp_tools: could not load the extra-YAML-keys store; ignoring "
|
||||
"it and granting no extra write keys.",
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
raw = data.get("keys")
|
||||
if raw is not None and not isinstance(raw, list):
|
||||
_LOGGER.warning(
|
||||
"ha_mcp_tools extra-YAML-keys store is malformed (keys is %s, "
|
||||
"expected list); ignoring it.",
|
||||
type(raw).__name__,
|
||||
)
|
||||
return []
|
||||
kept, dropped = _normalize_extra_yaml_keys(raw)
|
||||
if dropped:
|
||||
_LOGGER.warning(
|
||||
"ha_mcp_tools: dropped %d invalid entr%s from the persisted "
|
||||
"extra-YAML-keys store: %r",
|
||||
len(dropped),
|
||||
"y" if len(dropped) == 1 else "ies",
|
||||
dropped,
|
||||
)
|
||||
return kept
|
||||
|
||||
|
||||
async def _save_extra_yaml_keys(hass: HomeAssistant, keys: list[str]) -> None:
|
||||
"""Persist the user-configurable extra YAML write keys to .storage."""
|
||||
store: Store = Store(
|
||||
hass, _EXTRA_YAML_KEYS_STORAGE_VERSION, _EXTRA_YAML_KEYS_STORAGE_KEY
|
||||
)
|
||||
await store.async_save({"keys": keys})
|
||||
|
||||
|
||||
async def _apply_allowed_paths(
|
||||
hass: HomeAssistant, raw_paths: Any
|
||||
) -> tuple[list[str], list[Any]]:
|
||||
"""Normalize, persist, and hot-swap the extra directories (#1567, #1887).
|
||||
|
||||
Shared by the ``set_allowed_paths`` service and the tools-entry options flow
|
||||
so both edit the store through one validated path. Each entry runs through
|
||||
:func:`_normalize_extra_dir`; traversal / out-of-config / deny-floor entries
|
||||
are dropped into ``rejected``. Persists to .storage and updates hass.data so
|
||||
enforcement applies live. Returns ``(kept, rejected)``.
|
||||
"""
|
||||
config_dir = Path(hass.config.config_dir)
|
||||
normalized: list[str] = []
|
||||
rejected: list[Any] = []
|
||||
for entry in raw_paths if isinstance(raw_paths, list) else []:
|
||||
norm = (
|
||||
_normalize_extra_dir(entry, config_dir) if isinstance(entry, str) else None
|
||||
)
|
||||
if norm is None:
|
||||
rejected.append(entry)
|
||||
elif norm not in normalized:
|
||||
normalized.append(norm)
|
||||
await _save_allowed_paths(hass, normalized)
|
||||
hass.data.setdefault(DOMAIN, {})[_HASS_DATA_ALLOWED_PATHS_KEY] = normalized
|
||||
return normalized, rejected
|
||||
|
||||
|
||||
async def _apply_extra_yaml_keys(
|
||||
hass: HomeAssistant, raw_keys: Any
|
||||
) -> tuple[list[str], list[Any]]:
|
||||
"""Normalize, persist, and hot-swap the extra YAML write keys (#1887).
|
||||
|
||||
Shared by the ``set_extra_yaml_keys`` service and the tools-entry options
|
||||
flow. Delegates validation to :func:`_normalize_extra_yaml_keys` (strip,
|
||||
dedup, sort, drop denylist). Persists to .storage and updates hass.data so
|
||||
enforcement applies live. Returns ``(kept, rejected)``.
|
||||
"""
|
||||
normalized, rejected = _normalize_extra_yaml_keys(raw_keys)
|
||||
await _save_extra_yaml_keys(hass, normalized)
|
||||
hass.data.setdefault(DOMAIN, {})[_HASS_DATA_EXTRA_YAML_KEYS_KEY] = normalized
|
||||
return normalized, rejected
|
||||
|
||||
|
||||
def _unified_diff(before: str, after: str, rel_path: str, max_lines: int = 200) -> str:
|
||||
"""Unified diff of a prospective write, capped for response size."""
|
||||
lines = list(
|
||||
@@ -2095,7 +2259,10 @@ def _build_edit_yaml_config_handler(
|
||||
|
||||
# Parse and validate yaml_path (replaces the old ALLOWED_YAML_KEYS check)
|
||||
kind, path_parts, path_err = _parse_and_validate_yaml_path(
|
||||
yaml_path, is_package=is_package, is_theme=is_theme
|
||||
yaml_path,
|
||||
is_package=is_package,
|
||||
is_theme=is_theme,
|
||||
extra_allowed_keys=_effective_extra_allowed_keys(hass, call),
|
||||
)
|
||||
if path_err is not None:
|
||||
return {"success": False, "error": path_err}
|
||||
@@ -2218,18 +2385,59 @@ def _validate_lovelace_dashboard_path(
|
||||
return "lovelace_dashboard", parts, None
|
||||
|
||||
|
||||
def _caller_extra_allowed_keys(call: ServiceCall) -> frozenset[str]:
|
||||
"""Return the operator-configured extra write keys for this call (#1887).
|
||||
|
||||
``YAML_KEY_DENYLIST`` members are dropped here so they can never widen the
|
||||
``allowed`` set that the generic "not in the allowed list" rejection lists
|
||||
for some *other* invalid key. A direct write to a denied key does not rely
|
||||
on this drop: ``_parse_and_validate_yaml_path`` checks the denylist first
|
||||
and returns the categorical floor message before any allow-set is
|
||||
consulted. Keys already covered by the built-in sets are harmless
|
||||
duplicates and stay.
|
||||
"""
|
||||
return frozenset(
|
||||
key
|
||||
for key in call.data.get("extra_allowed_keys", [])
|
||||
if key and key not in YAML_KEY_DENYLIST
|
||||
)
|
||||
|
||||
|
||||
def _effective_extra_allowed_keys(
|
||||
hass: HomeAssistant, call: ServiceCall
|
||||
) -> frozenset[str]:
|
||||
"""Union the per-call wire keys with the component-stored keys (#1887).
|
||||
|
||||
The ha-mcp server passes its own ``HA_MCP_EXTRA_YAML_KEYS`` on the wire; the
|
||||
component's own options flow / settings store contributes
|
||||
:func:`_current_extra_yaml_keys`. Both sources are denylist-filtered - the
|
||||
wire in :func:`_caller_extra_allowed_keys`, the store on save/load - and
|
||||
:func:`_parse_and_validate_yaml_path` re-checks the deny floor regardless,
|
||||
so the union can never lift a forbidden key. The store side is filtered
|
||||
again here purely as defense in depth.
|
||||
"""
|
||||
stored = frozenset(
|
||||
key for key in _current_extra_yaml_keys(hass) if key not in YAML_KEY_DENYLIST
|
||||
)
|
||||
return _caller_extra_allowed_keys(call) | stored
|
||||
|
||||
|
||||
def _parse_and_validate_yaml_path(
|
||||
yaml_path: str,
|
||||
*,
|
||||
is_package: bool = False,
|
||||
is_theme: bool = False,
|
||||
extra_allowed_keys: frozenset[str] = frozenset(),
|
||||
) -> tuple[str, tuple[str, ...], str | None]:
|
||||
"""Parse and validate a yaml_path argument.
|
||||
|
||||
Three accepted shapes:
|
||||
1. Single segment in ALLOWED_YAML_KEYS -> kind='single'
|
||||
When ``is_package=True``, single segments in PACKAGES_ONLY_YAML_KEYS
|
||||
(automation, script, scene) are also accepted.
|
||||
(automation, script, scene) are also accepted. ``extra_allowed_keys``
|
||||
adds operator-opted-in keys on top of ALLOWED_YAML_KEYS (#1887); it
|
||||
overrides neither ``YAML_KEY_DENYLIST`` (filtered out before it gets
|
||||
here) nor the packages-only restriction.
|
||||
2. Exactly 'lovelace.dashboards.<url_path>' -> kind='lovelace_dashboard'
|
||||
3. Single segment theme name (no dots) when ``is_theme=True`` -> kind='theme'
|
||||
|
||||
@@ -2256,13 +2464,41 @@ def _parse_and_validate_yaml_path(
|
||||
# Shape 1: single key
|
||||
if len(parts) == 1:
|
||||
key = parts[0]
|
||||
# The deny floor is checked before every single-key accept branch, so
|
||||
# it holds even if a denied key is ever added to one of the allow
|
||||
# sets. It deliberately does not cover the theme branch above: under
|
||||
# ``is_theme`` the segment names a file in themes/, not a top-level
|
||||
# configuration key, so the same word carries no trust-boundary
|
||||
# meaning there.
|
||||
if key in YAML_KEY_DENYLIST:
|
||||
return (
|
||||
"",
|
||||
(),
|
||||
(
|
||||
f"Key '{yaml_path}' can never be edited through this "
|
||||
"service: it redefines Home Assistant's own trust "
|
||||
"boundary (authentication, proxy/CORS handling, or "
|
||||
"frontend module loading). This floor cannot be lifted "
|
||||
"by the extra-write-keys setting. Edit it by hand if "
|
||||
"you really need to change it."
|
||||
),
|
||||
)
|
||||
if key in ALLOWED_YAML_KEYS:
|
||||
return "single", parts, None
|
||||
if is_package and key in PACKAGES_ONLY_YAML_KEYS:
|
||||
return "single", parts, None
|
||||
# Operator extra keys widen ALLOWED_YAML_KEYS only. They deliberately
|
||||
# do NOT lift the packages-only restriction: those keys reach
|
||||
# packages/*.yaml through the branch above (still governed by their
|
||||
# per-key toggle) and stay rejected in configuration.yaml, so the
|
||||
# storage-mode/YAML-mode collision guarantee holds however the
|
||||
# operator fills the setting.
|
||||
if key in extra_allowed_keys and key not in PACKAGES_ONLY_YAML_KEYS:
|
||||
return "single", parts, None
|
||||
# Reaching here means the key was not accepted. If it is a
|
||||
# PACKAGES_ONLY key, we know is_package=False (otherwise the
|
||||
# preceding branch would have returned) — emit the targeted
|
||||
# PACKAGES_ONLY key, we know is_package=False (the packages branch
|
||||
# would have returned, and the extra-keys branch excludes them
|
||||
# precisely so this guidance still fires) – emit the targeted
|
||||
# "move it to a package file" guidance instead of the generic
|
||||
# allowlist dump below.
|
||||
if key in PACKAGES_ONLY_YAML_KEYS:
|
||||
@@ -2278,9 +2514,9 @@ def _parse_and_validate_yaml_path(
|
||||
),
|
||||
)
|
||||
allowed = (
|
||||
ALLOWED_YAML_KEYS | PACKAGES_ONLY_YAML_KEYS
|
||||
ALLOWED_YAML_KEYS | PACKAGES_ONLY_YAML_KEYS | extra_allowed_keys
|
||||
if is_package
|
||||
else ALLOWED_YAML_KEYS
|
||||
else ALLOWED_YAML_KEYS | extra_allowed_keys
|
||||
)
|
||||
return (
|
||||
"",
|
||||
@@ -2421,6 +2657,16 @@ def _current_extra_dirs(hass: HomeAssistant) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def _current_extra_yaml_keys(hass: HomeAssistant) -> list[str]:
|
||||
"""Return the live component-configured extra YAML write keys from hass.data."""
|
||||
domain_data = hass.data.get(DOMAIN)
|
||||
if isinstance(domain_data, dict):
|
||||
keys = domain_data.get(_HASS_DATA_EXTRA_YAML_KEYS_KEY)
|
||||
if isinstance(keys, list):
|
||||
return keys
|
||||
return []
|
||||
|
||||
|
||||
def _build_list_files_handler(
|
||||
hass: HomeAssistant,
|
||||
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
||||
@@ -2520,9 +2766,11 @@ async def _shape_read_file_response(
|
||||
# Apply special handling for specific files
|
||||
normalized = os.path.normpath(rel_path) # noqa: ASYNC240
|
||||
|
||||
# Mask secrets.yaml
|
||||
# Mask secrets.yaml. Offloaded because the first make_yaml() call on a
|
||||
# thread constructs a ruamel YAML instance, whose plugin discovery globs
|
||||
# the site-packages tree — blocking work that must stay off the loop.
|
||||
if normalized == "secrets.yaml":
|
||||
content = _mask_secrets_content(content)
|
||||
content = await hass.async_add_executor_job(_mask_secrets_content, content)
|
||||
|
||||
# Apply tail for log files
|
||||
if normalized == "home-assistant.log":
|
||||
@@ -2845,8 +3093,12 @@ def _build_get_caller_token_handler(
|
||||
# uses everywhere else.
|
||||
try:
|
||||
integration = await async_get_integration(hass, DOMAIN)
|
||||
if integration.version is None:
|
||||
# Reads as None rather than raising; an unreadable version and
|
||||
# an absent one deserve the same answer.
|
||||
raise ValueError("the manifest carries no version")
|
||||
version = str(integration.version)
|
||||
except Exception as exc: # pragma: no cover — manifest sanity
|
||||
except Exception as exc:
|
||||
_LOGGER.warning(
|
||||
"Could not read ha_mcp_tools manifest version for "
|
||||
"get_caller_token response: %s",
|
||||
@@ -2906,7 +3158,6 @@ def _build_set_allowed_paths_handler(
|
||||
hass: HomeAssistant,
|
||||
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
||||
"""Build the handle_set_allowed_paths service handler."""
|
||||
config_dir = Path(hass.config.config_dir)
|
||||
|
||||
async def handle_set_allowed_paths(call: ServiceCall) -> ServiceResponse:
|
||||
"""Replace the user-configurable extra directories (issues #1567, #1586).
|
||||
@@ -2929,18 +3180,9 @@ def _build_set_allowed_paths_handler(
|
||||
"error": "ha_mcp_tools.set_allowed_paths requires admin auth.",
|
||||
"paths": [],
|
||||
}
|
||||
raw_paths = call.data.get("paths", [])
|
||||
normalized: list[str] = []
|
||||
rejected: list[str] = []
|
||||
for entry in raw_paths:
|
||||
norm = _normalize_extra_dir(entry, config_dir)
|
||||
if norm is None:
|
||||
rejected.append(entry)
|
||||
elif norm not in normalized:
|
||||
normalized.append(norm)
|
||||
|
||||
await _save_allowed_paths(hass, normalized)
|
||||
hass.data.setdefault(DOMAIN, {})[_HASS_DATA_ALLOWED_PATHS_KEY] = normalized
|
||||
normalized, rejected = await _apply_allowed_paths(
|
||||
hass, call.data.get("paths", [])
|
||||
)
|
||||
_LOGGER.info(
|
||||
"Updated ha_mcp_tools custom filesystem directories: %s (%d rejected)",
|
||||
normalized,
|
||||
@@ -2955,6 +3197,77 @@ def _build_set_allowed_paths_handler(
|
||||
return handle_set_allowed_paths
|
||||
|
||||
|
||||
def _build_get_extra_yaml_keys_handler(
|
||||
hass: HomeAssistant,
|
||||
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
||||
"""Build the handle_get_extra_yaml_keys service handler (#1887)."""
|
||||
|
||||
async def handle_get_extra_yaml_keys(call: ServiceCall) -> ServiceResponse:
|
||||
"""Return the component-configured extra YAML write keys plus the
|
||||
non-overridable deny floor.
|
||||
|
||||
Backs the component's own options flow and the ha-mcp settings UI, and
|
||||
is how the server reads this store to union it with its own
|
||||
``HA_MCP_EXTRA_YAML_KEYS``. Caller-token + admin gated, matching
|
||||
get_allowed_paths.
|
||||
"""
|
||||
if not _caller_token_ok(hass, call):
|
||||
return _unauthorized_response(SERVICE_GET_EXTRA_YAML_KEYS, keys=[])
|
||||
if not await _caller_is_admin(hass, call):
|
||||
return {
|
||||
"success": False,
|
||||
"error_code": "unauthorized",
|
||||
"error": "ha_mcp_tools.get_extra_yaml_keys requires admin auth.",
|
||||
"keys": [],
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"keys": _current_extra_yaml_keys(hass),
|
||||
"deny_floor": sorted(YAML_KEY_DENYLIST),
|
||||
}
|
||||
|
||||
return handle_get_extra_yaml_keys
|
||||
|
||||
|
||||
def _build_set_extra_yaml_keys_handler(
|
||||
hass: HomeAssistant,
|
||||
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
||||
"""Build the handle_set_extra_yaml_keys service handler (#1887)."""
|
||||
|
||||
async def handle_set_extra_yaml_keys(call: ServiceCall) -> ServiceResponse:
|
||||
"""Replace the component-configured extra YAML write keys.
|
||||
|
||||
Receives the FULL replacement list. Each entry is stripped and
|
||||
validated; blanks and ``YAML_KEY_DENYLIST`` members are dropped and
|
||||
reported in ``rejected``. Persists to .storage AND updates hass.data so
|
||||
enforcement applies live with no HA restart. Caller-token + admin gated.
|
||||
"""
|
||||
if not _caller_token_ok(hass, call):
|
||||
return _unauthorized_response(SERVICE_SET_EXTRA_YAML_KEYS, keys=[])
|
||||
if not await _caller_is_admin(hass, call):
|
||||
return {
|
||||
"success": False,
|
||||
"error_code": "unauthorized",
|
||||
"error": "ha_mcp_tools.set_extra_yaml_keys requires admin auth.",
|
||||
"keys": [],
|
||||
}
|
||||
normalized, rejected = await _apply_extra_yaml_keys(
|
||||
hass, call.data.get("keys", [])
|
||||
)
|
||||
_LOGGER.info(
|
||||
"Updated ha_mcp_tools extra YAML write keys: %s (%d rejected)",
|
||||
normalized,
|
||||
len(rejected),
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"keys": normalized,
|
||||
"rejected": rejected,
|
||||
}
|
||||
|
||||
return handle_set_extra_yaml_keys
|
||||
|
||||
|
||||
def _build_list_legacy_backups_handler(
|
||||
hass: HomeAssistant,
|
||||
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
||||
@@ -3063,6 +3376,13 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b
|
||||
_HASS_DATA_ALLOWED_PATHS_KEY
|
||||
] = await _load_allowed_paths(hass)
|
||||
|
||||
# Load the component-configured extra YAML write keys (#1887) into hass.data
|
||||
# so enforcement reads them with no I/O. set_extra_yaml_keys updates this in
|
||||
# place, so changes apply live (no HA restart).
|
||||
hass.data.setdefault(DOMAIN, {})[
|
||||
_HASS_DATA_EXTRA_YAML_KEYS_KEY
|
||||
] = await _load_extra_yaml_keys(hass)
|
||||
|
||||
# One-time migration of pre-fix YAML backups out of the publicly-served
|
||||
# www/ directory (GHSA-g39v-cvjh-8fpf). Wrapped so a migration failure
|
||||
# cannot prevent the integration from loading — the integration's
|
||||
@@ -3132,6 +3452,8 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b
|
||||
handle_get_caller_token = _build_get_caller_token_handler(hass)
|
||||
handle_get_allowed_paths = _build_get_allowed_paths_handler(hass)
|
||||
handle_set_allowed_paths = _build_set_allowed_paths_handler(hass)
|
||||
handle_get_extra_yaml_keys = _build_get_extra_yaml_keys_handler(hass)
|
||||
handle_set_extra_yaml_keys = _build_set_extra_yaml_keys_handler(hass)
|
||||
handle_list_legacy_backups = _build_list_legacy_backups_handler(hass)
|
||||
handle_read_legacy_backup = _build_read_legacy_backup_handler(hass)
|
||||
|
||||
@@ -3200,6 +3522,22 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GET_EXTRA_YAML_KEYS,
|
||||
handle_get_extra_yaml_keys,
|
||||
schema=SERVICE_GET_EXTRA_YAML_KEYS_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SET_EXTRA_YAML_KEYS,
|
||||
handle_set_extra_yaml_keys,
|
||||
schema=SERVICE_SET_EXTRA_YAML_KEYS_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_LIST_LEGACY_BACKUPS,
|
||||
@@ -3242,10 +3580,17 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b
|
||||
component_version = COMPONENT_VERSION
|
||||
try:
|
||||
integration = await async_get_integration(hass, DOMAIN)
|
||||
if integration.version is None:
|
||||
# A manifest without a version reads as None rather than raising,
|
||||
# and ``str()`` would put the literal "None" on the device.
|
||||
raise ValueError("the manifest carries no version")
|
||||
component_version = str(integration.version)
|
||||
except Exception as err:
|
||||
_LOGGER.debug(
|
||||
"Could not read the component version for the tools device: %s", err
|
||||
"Could not read the component version for the tools device, using "
|
||||
"the compiled-in %s: %s",
|
||||
COMPONENT_VERSION,
|
||||
err,
|
||||
)
|
||||
dr.async_get(hass).async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
|
||||
@@ -20,11 +20,13 @@ options flow, and the tools entry gets a light informational options flow
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigEntryState,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
@@ -106,6 +108,229 @@ _SERVER_UNIQUE_ID = f"{DOMAIN}-server"
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# The options form's prose is assembled here rather than in strings.json,
|
||||
# because which sentences appear depends on runtime state. Keeping the text
|
||||
# itself in the ``common`` catalog means the assembled paragraphs follow the
|
||||
# system-configured language instead of being English inside an otherwise
|
||||
# translated form. These are the English source strings and the fallback: if
|
||||
# the language is unreadable or the catalog cannot be loaded, the form still
|
||||
# renders, in English, exactly as it did before.
|
||||
#
|
||||
# Kept identical to ``strings.json``'s ``common`` block, keys and values —
|
||||
# asserted by ``test_common_fallbacks_mirror_strings_json`` in
|
||||
# tests/src/unit/test_config_flow.py, because a fallback that has drifted
|
||||
# from the source shows different English than every catalog.
|
||||
_COMMON_FALLBACKS: dict[str, str] = {
|
||||
"panel_hint": (
|
||||
"Open the [HA-MCP settings panel](/ha-mcp) for tool management and "
|
||||
"server settings."
|
||||
),
|
||||
"version_line": (
|
||||
"Component {component_version} - "
|
||||
"Server ha-mcp {server_version} ({channel} channel)"
|
||||
),
|
||||
"version_unknown": "unknown",
|
||||
"version_not_installed": "not installed yet",
|
||||
"tools_module_installed": (
|
||||
"Beta/advanced file & YAML tools module (optional): Installed"
|
||||
),
|
||||
"tools_module_not_loaded": (
|
||||
"Beta/advanced file & YAML tools module (optional): Installed "
|
||||
'but not loaded — enable or reload the "HA-MCP File & YAML '
|
||||
"Tools\" entry on this integration's page"
|
||||
),
|
||||
"tools_module_not_installed": (
|
||||
"Beta/advanced file & YAML tools module (optional): Not installed — "
|
||||
'press "Add entry" on this integration\'s page and choose '
|
||||
'"HA-MCP File & YAML Tools" to add it'
|
||||
),
|
||||
"connect_urls_pending": (
|
||||
"The connect URLs appear here (and in the Home Assistant log) "
|
||||
"once the server has started."
|
||||
),
|
||||
"connect_urls_label": "Connect URL(s):",
|
||||
"connect_webhook_disabled": (
|
||||
"Remote access via webhook is disabled (local-only mode)."
|
||||
),
|
||||
"connect_direct_access": "Direct access from the Home Assistant machine: {url}",
|
||||
"connect_remote_url": "Remote connect URL: {url}",
|
||||
"connect_local_lan": 'Local/LAN (when Network access is "Local network"): {url}',
|
||||
"oauth_select_legacy_mode": (
|
||||
"Set Authentication mode to legacy OAuth above and save to "
|
||||
"generate a Client ID and Client Secret."
|
||||
),
|
||||
"oauth_creds_pending": (
|
||||
"The Client ID and Client Secret appear here once the server has started."
|
||||
),
|
||||
"oauth_not_serving": (
|
||||
"Legacy OAuth is not serving these yet — restart Home Assistant "
|
||||
"when it asks you to, to activate them."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _fill(common: dict[str, str], key: str, /, **values: str) -> str:
|
||||
"""Return the ``common`` string ``key`` with ``values`` substituted.
|
||||
|
||||
Placeholder parity is asserted in tests/src/unit/test_locale_parity.py, but
|
||||
a catalog is data: one malformed brace, or a placeholder the parity check
|
||||
cannot see (``{component_version.major}`` reads as no placeholder at all),
|
||||
would otherwise take the whole options form down. The English source is a
|
||||
module constant, so formatting it after a failure needs no second guard.
|
||||
"""
|
||||
try:
|
||||
return common[key].format(**values)
|
||||
except Exception as err:
|
||||
# Names both causes: a catalog string this caller cannot fill, or a
|
||||
# caller passing values the template never declared. The second is our
|
||||
# bug and crashes on the English constant below, so the log line has to
|
||||
# point at the caller rather than blame the translator.
|
||||
_LOGGER.warning(
|
||||
"Unusable %s template (%r) — bad catalog string or wrong caller "
|
||||
"arguments; using the English source: %s",
|
||||
key,
|
||||
common.get(key),
|
||||
err,
|
||||
)
|
||||
return _COMMON_FALLBACKS[key].format(**values)
|
||||
|
||||
|
||||
# Scripts that set their own inter-sentence spacing: the full-width punctuation
|
||||
# they end on already carries it, so an ASCII space after it renders as a gap.
|
||||
#
|
||||
# Keyed off the language, not off the last character. Sniffing glyphs got it
|
||||
# wrong in both directions: U+201D (”) is Simplified Chinese's closing quote and
|
||||
# was removed as "Latin", while 「」『』 are the traditional forms zh-Hans does
|
||||
# not use and were kept.
|
||||
#
|
||||
# ``ko`` is deliberately absent. Korean separates words with ASCII spaces and
|
||||
# ends sentences on an ASCII full stop, so a future ``ko`` catalog wants the
|
||||
# separator exactly like a Latin one — the full-width rationale above simply
|
||||
# does not apply to it.
|
||||
_NO_ASCII_SENTENCE_SPACE = frozenset({"zh", "ja"})
|
||||
|
||||
|
||||
def _sentence_prefix(sentence: str, language: str, english: str) -> str:
|
||||
"""Return ``sentence`` spaced to run into the prose that follows it.
|
||||
|
||||
Two inputs decide this, and each alone has already been wrong once. The
|
||||
language names the script, which the last character cannot. But the
|
||||
language does not promise the text follows it: core loads
|
||||
``[en, <language>]`` and merges English first as the documented fallback
|
||||
(``helpers/translation.py``), so an instance set to a language this
|
||||
integration does not ship reads these sentences in English — and English
|
||||
needs the ASCII separator whatever ``hass.config.language`` says. The same
|
||||
holds for a shipped language whenever the catalog load degrades.
|
||||
|
||||
``english`` is the English source for this sentence; when the catalog
|
||||
hands back exactly that, the rendered text is English and gets the space.
|
||||
"""
|
||||
if not sentence:
|
||||
return sentence
|
||||
if (
|
||||
language.split("-", maxsplit=1)[0].lower() in _NO_ASCII_SENTENCE_SPACE
|
||||
and sentence != english
|
||||
):
|
||||
return sentence
|
||||
return f"{sentence} "
|
||||
|
||||
|
||||
async def _fetch_common_translations(
|
||||
hass: HomeAssistant, language: str
|
||||
) -> dict[str, str]:
|
||||
"""core ``async_get_translations(hass, language, "common")``; test seam.
|
||||
|
||||
Mirrors the seam in ``websocket_api`` so the lookup can be replaced in
|
||||
tests without reaching into Home Assistant's translation machinery.
|
||||
"""
|
||||
from homeassistant.helpers.translation import async_get_translations
|
||||
|
||||
result = await async_get_translations(hass, language, "common", {DOMAIN})
|
||||
# Any Mapping, not just dict: core returns a plain dict today, but the
|
||||
# mirrored seam in ``websocket_api`` accepts a Mapping, and narrowing it
|
||||
# here would silently discard a whole catalog on a core-internal change.
|
||||
if isinstance(result, Mapping):
|
||||
return dict(result)
|
||||
# Discarding a whole catalog is the same pure-English outcome as a failed
|
||||
# load, so it gets the same visibility; the type is the only useful clue.
|
||||
_LOGGER.warning(
|
||||
"Ignoring the %s common translations: expected a Mapping, got %s",
|
||||
language,
|
||||
type(result).__name__,
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
async def _common_strings(hass: HomeAssistant | None) -> tuple[dict[str, str], str]:
|
||||
"""Return the ``common`` catalog and the language it was fetched for.
|
||||
|
||||
``hass.config.language`` is the instance-wide language, not the profile
|
||||
language of the administrator who opened the form — an options flow is
|
||||
handed no requester language (Home Assistant's flow context carries
|
||||
``source`` and ``entry_id`` only), so where the two differ this prose
|
||||
follows the system setting while the surrounding form follows the user.
|
||||
|
||||
The language is returned rather than left for the caller to read again:
|
||||
the sentence separator needs it, and two independent reads of the same
|
||||
attribute can disagree about which catalog is actually in hand. Here they
|
||||
cannot — this is the only place the attribute is read, and ``en`` is what
|
||||
both the fallback strings and the returned language say when it is
|
||||
unreadable.
|
||||
|
||||
Failure-proof like the hints it feeds: an unreadable language or a
|
||||
failing lookup degrades to the English source strings rather than
|
||||
breaking the options form.
|
||||
"""
|
||||
strings = dict(_COMMON_FALLBACKS)
|
||||
configured = getattr(getattr(hass, "config", None), "language", None)
|
||||
if hass is None or not isinstance(configured, str):
|
||||
return strings, "en"
|
||||
language = configured
|
||||
try:
|
||||
loaded = await _fetch_common_translations(hass, language)
|
||||
except Exception as err:
|
||||
# Warning, not debug: this is a degradation an administrator can see
|
||||
# in the form (English paragraphs inside a translated page) and the
|
||||
# broad ``except`` also covers an ImportError from the function-local
|
||||
# core import — a permanent defect nobody would ever notice at debug.
|
||||
# ``exc_info`` because the traceback is the only way to tell the two
|
||||
# apart. Same level the ``websocket_api`` seam this mirrors uses.
|
||||
_LOGGER.warning(
|
||||
"Could not load the %s options-form translations, falling back to "
|
||||
"English: %s",
|
||||
language,
|
||||
err,
|
||||
exc_info=True,
|
||||
)
|
||||
return strings, language
|
||||
|
||||
prefix = f"component.{DOMAIN}.common."
|
||||
translated = {
|
||||
key.removeprefix(prefix): value
|
||||
for key, value in loaded.items()
|
||||
if key.startswith(prefix) and isinstance(value, str) and value
|
||||
}
|
||||
if not translated:
|
||||
# Deliberately not ``if loaded and not translated``: core returns a
|
||||
# single-component lookup straight from that component's cache entry
|
||||
# (``_TranslationCache.get_cached``), so a category that was never
|
||||
# built arrives as ``{}`` — which is exactly the developer error worth
|
||||
# seeing, and an empty-``loaded`` condition would skip it. The merge is
|
||||
# a silent no-op either way: the form renders pure English and no other
|
||||
# check notices.
|
||||
# Wording covers both ways to get here: a catalog that carries nothing
|
||||
# under our prefix, and one the seam already discarded and warned about
|
||||
# (where "carries no keys" would be untrue — there was no catalog).
|
||||
_LOGGER.warning(
|
||||
"No usable %s translations under %s, so the options form renders "
|
||||
"its assembled prose in English",
|
||||
language,
|
||||
prefix,
|
||||
)
|
||||
strings.update(translated)
|
||||
return strings, language
|
||||
|
||||
|
||||
def _legacy_credentials_active(
|
||||
hass: HomeAssistant, client_id: str, client_secret: str, signing_key: str
|
||||
) -> bool:
|
||||
@@ -240,29 +465,70 @@ class HaMcpToolsConfigFlow(ConfigFlow, domain=DOMAIN): # type: ignore[call-arg]
|
||||
|
||||
|
||||
class HaMcpToolsInfoOptionsFlow(OptionsFlow):
|
||||
"""Options flow for the tools entry: a light informational form.
|
||||
"""Options flow for the tools entry: edit the privileged services' config.
|
||||
|
||||
The tools services entry has nothing to configure yet, but aborting the
|
||||
Configure dialog reads as an error. Show an empty-schema form that explains
|
||||
what the entry provides instead; submitting persists an empty options
|
||||
payload.
|
||||
Surfaces the two operator-tunable sets the file/YAML tools honour - the
|
||||
extra read/write directories and the extra top-level YAML write keys - so
|
||||
they are reachable from the integration UI, not only the ha-mcp server's own
|
||||
settings. Both live in the component's own .storage (get/set_allowed_paths
|
||||
and get/set_extra_yaml_keys), so this screen and the server settings UI edit
|
||||
the same source of truth, applied live with no restart. The deny floor is
|
||||
non-overridable: traversal / out-of-config directories and denylisted keys
|
||||
are dropped on save.
|
||||
|
||||
The form uses the ``tools_info`` step id, NOT ``init``: the server options
|
||||
flow already owns ``options.step.init`` in strings.json, so a shared step id
|
||||
would collide. ``async_step_init`` is the required entry point (it renders
|
||||
the form); HA routes the form's submit to ``async_step_tools_info``.
|
||||
would collide. ``async_step_init`` renders the form; HA routes the form's
|
||||
submit to ``async_step_tools_info``.
|
||||
"""
|
||||
|
||||
def _tools_form_schema(self) -> vol.Schema:
|
||||
"""Build the form schema with the current stored values as defaults."""
|
||||
from . import _current_extra_dirs, _current_extra_yaml_keys
|
||||
|
||||
current_dirs = _current_extra_dirs(self.hass)
|
||||
current_keys = _current_extra_yaml_keys(self.hass)
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Optional("allowed_dirs", default=current_dirs): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=current_dirs,
|
||||
multiple=True,
|
||||
custom_value=True,
|
||||
mode=SelectSelectorMode.LIST,
|
||||
)
|
||||
),
|
||||
vol.Optional("extra_yaml_keys", default=current_keys): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=current_keys,
|
||||
multiple=True,
|
||||
custom_value=True,
|
||||
mode=SelectSelectorMode.LIST,
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Render the informational form under the ``tools_info`` step id."""
|
||||
return self.async_show_form(step_id="tools_info", data_schema=vol.Schema({}))
|
||||
"""Render the editable form under the ``tools_info`` step id."""
|
||||
return self.async_show_form(
|
||||
step_id="tools_info", data_schema=self._tools_form_schema()
|
||||
)
|
||||
|
||||
async def async_step_tools_info(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Persist an empty options payload once the info form is submitted."""
|
||||
"""Persist the edited directories and keys once the form is submitted."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="tools_info", data_schema=self._tools_form_schema()
|
||||
)
|
||||
from . import _apply_allowed_paths, _apply_extra_yaml_keys
|
||||
|
||||
await _apply_allowed_paths(self.hass, user_input.get("allowed_dirs", []))
|
||||
await _apply_extra_yaml_keys(self.hass, user_input.get("extra_yaml_keys", []))
|
||||
return self.async_create_entry(title="", data={})
|
||||
|
||||
|
||||
@@ -434,21 +700,30 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
# The sidebar-panel sentence in the description is only truthful while
|
||||
# the panel is registered; drop it (from the CURRENT stored options, not
|
||||
# the unsaved form state) when the panel is off so the link cannot point
|
||||
# at a route that 404s. The trailing space keeps the surrounding prose
|
||||
# spaced correctly whether the sentence is present or empty.
|
||||
# at a route that 404s. The separator keeps the surrounding prose spaced
|
||||
# correctly whether the sentence is present or empty.
|
||||
common, language = await _common_strings(getattr(self, "hass", None))
|
||||
panel_hint = (
|
||||
"Open the [HA-MCP settings panel](/ha-mcp) for tool management and "
|
||||
"server settings. "
|
||||
_sentence_prefix(
|
||||
common["panel_hint"], language, _COMMON_FALLBACKS["panel_hint"]
|
||||
)
|
||||
if bool(opts.get(OPT_ENABLE_SIDEBAR_PANEL, True))
|
||||
else ""
|
||||
)
|
||||
# The tools-module status renders as its own paragraph directly under
|
||||
# the version line, sharing the {versions} placeholder so every
|
||||
# translation shows it without a strings change.
|
||||
versions = await self._versions_hint(common)
|
||||
tools_hint = self._tools_module_hint(common)
|
||||
if tools_hint:
|
||||
versions = f"{versions}\n\n{tools_hint}"
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=schema,
|
||||
description_placeholders={
|
||||
"versions": await self._versions_hint(),
|
||||
"connect_url": await self._connect_url_hint(),
|
||||
"oauth_creds": self._oauth_creds_hint(),
|
||||
"versions": versions,
|
||||
"connect_url": await self._connect_url_hint(common),
|
||||
"oauth_creds": self._oauth_creds_hint(common),
|
||||
"llm_api_docs_url": LLM_API_DOCS_URL,
|
||||
"panel_hint": panel_hint,
|
||||
},
|
||||
@@ -494,7 +769,7 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
cleaned.pop(OPT_SERVER_URL, None)
|
||||
return cleaned
|
||||
|
||||
async def _versions_hint(self) -> str:
|
||||
async def _versions_hint(self, common: dict[str, str]) -> str:
|
||||
"""Return a one-line component + server version summary for the form.
|
||||
|
||||
Reads the component version from the integration manifest and the
|
||||
@@ -506,15 +781,28 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
opts = self.config_entry.options
|
||||
channel = str(opts.get(OPT_CHANNEL) or DEFAULT_CHANNEL)
|
||||
|
||||
component_version = "unknown"
|
||||
component_version = common["version_unknown"]
|
||||
hass = getattr(self, "hass", None)
|
||||
if hass is not None:
|
||||
try:
|
||||
integration = await async_get_integration(hass, DOMAIN)
|
||||
component_version = str(integration.version)
|
||||
# A manifest without a version yields None, not an exception —
|
||||
# ``str()`` would render the literal "None" into the form and
|
||||
# the "unknown" wording would never appear for the likeliest
|
||||
# defect it exists for.
|
||||
if integration.version is not None:
|
||||
component_version = str(integration.version)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"The %s manifest carries no version; the options form "
|
||||
"shows the unknown-version wording",
|
||||
DOMAIN,
|
||||
)
|
||||
except Exception as err:
|
||||
_LOGGER.debug(
|
||||
"Could not read component version for the options hint: %s", err
|
||||
_LOGGER.warning(
|
||||
"Could not read the component version for the options hint, "
|
||||
"showing the unknown-version wording: %s",
|
||||
err,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -525,31 +813,80 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
if hass is not None
|
||||
else _installed_server_version()
|
||||
)
|
||||
server_version = raw_version or "not installed yet"
|
||||
server_version = raw_version or common["version_not_installed"]
|
||||
except Exception as err:
|
||||
_LOGGER.debug("Could not read server version for the options hint: %s", err)
|
||||
server_version = "not installed yet"
|
||||
_LOGGER.warning(
|
||||
"Could not read the server version for the options hint, showing "
|
||||
"the not-installed wording: %s",
|
||||
err,
|
||||
)
|
||||
server_version = common["version_not_installed"]
|
||||
|
||||
return (
|
||||
f"Component {component_version} - "
|
||||
f"Server ha-mcp {server_version} ({channel} channel)"
|
||||
return _fill(
|
||||
common,
|
||||
"version_line",
|
||||
component_version=component_version,
|
||||
server_version=server_version,
|
||||
channel=channel,
|
||||
)
|
||||
|
||||
async def _connect_url_hint(self) -> str:
|
||||
def _tools_module_hint(self, common: dict[str, str]) -> str | None:
|
||||
"""Return the File & YAML tools entry status line, or None if unreadable.
|
||||
|
||||
Shown directly under the version line (#1996): users routinely add the
|
||||
server entry only and never learn the file / YAML tools need the second
|
||||
"HA-MCP File & YAML Tools" entry until a tool call fails. An entry
|
||||
that exists but is not loaded (disabled, or setup failed) serves no
|
||||
services either, so it reports "not loaded" rather than Installed.
|
||||
Failure-proof like the other hints: any read error drops the line
|
||||
rather than breaking the options form.
|
||||
"""
|
||||
hass = getattr(self, "hass", None)
|
||||
if hass is None:
|
||||
return None
|
||||
try:
|
||||
# A missing entry_type means tools (pre-#1527 entries never carried
|
||||
# the discriminator) — same default async_setup_entry dispatches on.
|
||||
tools_entries = [
|
||||
entry
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.data.get(CONF_ENTRY_TYPE, ENTRY_TYPE_TOOLS) == ENTRY_TYPE_TOOLS
|
||||
]
|
||||
loaded = any(
|
||||
entry.state is ConfigEntryState.LOADED for entry in tools_entries
|
||||
)
|
||||
except Exception as err:
|
||||
# Warning, like the two version reads above: this drops the whole
|
||||
# tools-module paragraph from the form, which is a larger visible
|
||||
# loss than either of those fallbacks.
|
||||
_LOGGER.warning(
|
||||
"Could not read the tools-entry state, dropping the "
|
||||
"tools-module line from the options form: %s",
|
||||
err,
|
||||
)
|
||||
return None
|
||||
if loaded:
|
||||
return common["tools_module_installed"]
|
||||
if tools_entries:
|
||||
return common["tools_module_not_loaded"]
|
||||
return common["tools_module_not_installed"]
|
||||
|
||||
async def _connect_url_hint(self, common: dict[str, str]) -> str:
|
||||
"""Return the connect URLs for the options form.
|
||||
|
||||
The Configure screen is admin-only, so it shows the real resolved
|
||||
URLs (the start-up notification deliberately does not - it is visible
|
||||
to every signed-in user). Falls back to a placeholder form when
|
||||
resolution is unavailable.
|
||||
|
||||
The URLs themselves and the two ``<...>`` stand-ins stay verbatim: they
|
||||
are addresses to copy, not prose. Everything around them comes from the
|
||||
``common`` catalog.
|
||||
"""
|
||||
webhook_id = self.config_entry.data.get(DATA_WEBHOOK_ID)
|
||||
secret_path = self.config_entry.data.get(DATA_SECRET_PATH)
|
||||
if not webhook_id:
|
||||
return (
|
||||
"The connect URLs appear here (and in the Home Assistant log) "
|
||||
"once the server has started."
|
||||
)
|
||||
return common["connect_urls_pending"]
|
||||
webhook_enabled = bool(self.config_entry.options.get(OPT_ENABLE_WEBHOOK, True))
|
||||
port = self.config_entry.options.get(OPT_SERVER_PORT, DEFAULT_SERVER_PORT)
|
||||
hass = getattr(self, "hass", None)
|
||||
@@ -564,7 +901,8 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
extra_hosts=await async_get_lan_hosts(hass),
|
||||
)
|
||||
if urls:
|
||||
return "Connect URL(s):\n" + "\n".join(f"- {u}" for u in urls)
|
||||
listed = "\n".join(f"- {u}" for u in urls)
|
||||
return f"{common['connect_urls_label']}\n{listed}"
|
||||
except Exception as err:
|
||||
# The hint is auxiliary display data: a resolution bug must not
|
||||
# take down the whole options form, but the degradation should
|
||||
@@ -576,26 +914,32 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
# Local-only mode: the webhook endpoint is never registered, so
|
||||
# a webhook URL here would 404. With loopback binding the builder
|
||||
# resolves no URLs at all - state that instead of inventing one.
|
||||
hint = "Remote access via webhook is disabled (local-only mode)."
|
||||
hint = common["connect_webhook_disabled"]
|
||||
if secret_path:
|
||||
hint += (
|
||||
f"\nDirect access from the Home Assistant machine: "
|
||||
f"http://127.0.0.1:{port}{secret_path}"
|
||||
direct = _fill(
|
||||
common,
|
||||
"connect_direct_access",
|
||||
url=f"http://127.0.0.1:{port}{secret_path}",
|
||||
)
|
||||
hint += f"\n{direct}"
|
||||
return hint
|
||||
external = str(self.config_entry.options.get(OPT_EXTERNAL_URL) or "").rstrip(
|
||||
"/"
|
||||
)
|
||||
base = external or "<your-home-assistant-url>"
|
||||
hint = f"Remote connect URL: {base}/api/webhook/{webhook_id}"
|
||||
hint = _fill(
|
||||
common, "connect_remote_url", url=f"{base}/api/webhook/{webhook_id}"
|
||||
)
|
||||
if secret_path:
|
||||
hint += (
|
||||
f"\nLocal/LAN (when bind host is 0.0.0.0): "
|
||||
f"http://<home-assistant-ip>:{port}{secret_path}"
|
||||
lan = _fill(
|
||||
common,
|
||||
"connect_local_lan",
|
||||
url=f"http://<home-assistant-ip>:{port}{secret_path}",
|
||||
)
|
||||
hint += f"\n{lan}"
|
||||
return hint
|
||||
|
||||
def _oauth_creds_hint(self) -> str:
|
||||
def _oauth_creds_hint(self, common: dict[str, str]) -> str:
|
||||
"""Return the resolved legacy OAuth Client ID + Secret for the options
|
||||
form, or a note pointing at the mode selector when legacy mode isn't
|
||||
the CONFIGURED mode. Admin-only screen (like ``_connect_url_hint``),
|
||||
@@ -605,23 +949,21 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
log, where a still-valid old-identity token could read them; an HA
|
||||
admin here is trusted). A pending rotation gets a caveat so the admin
|
||||
doesn't paste values that only start working after the restart.
|
||||
|
||||
The ``Client ID`` / ``Client Secret`` labels stay verbatim: they name
|
||||
the two fields the client an admin pastes them into asks for, and those
|
||||
clients label them in English whatever this instance's language is.
|
||||
"""
|
||||
configured_mode = str(self.config_entry.options.get(OPT_WEBHOOK_AUTH) or "")
|
||||
if configured_mode != WEBHOOK_AUTH_LEGACY:
|
||||
return (
|
||||
"Set Authentication mode to legacy OAuth above and save to "
|
||||
"generate a Client ID and Client Secret."
|
||||
)
|
||||
return common["oauth_select_legacy_mode"]
|
||||
client_id = self.config_entry.data.get(DATA_OAUTH_CLIENT_ID)
|
||||
client_secret = self.config_entry.data.get(DATA_OAUTH_CLIENT_SECRET)
|
||||
if not client_id or not client_secret:
|
||||
# Not minted yet — the entry hasn't finished a bring-up cycle
|
||||
# since legacy mode was selected (e.g. this save just turned it
|
||||
# on). They appear after the next reload.
|
||||
return (
|
||||
"The Client ID and Client Secret appear here once the server "
|
||||
"has started."
|
||||
)
|
||||
return common["oauth_creds_pending"]
|
||||
creds = f"Client ID: {client_id}\nClient Secret: {client_secret}"
|
||||
signing_key = str(self.config_entry.data.get(DATA_OAUTH_SIGNING_KEY) or "")
|
||||
active = _legacy_credentials_active(
|
||||
@@ -636,8 +978,4 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
# previous Client ID and Client Secret remain active" was false at a
|
||||
# first enable and when nothing of ours is bound. Matches the startup
|
||||
# log's first-enable caveat and the oauth_regenerate help text.
|
||||
return (
|
||||
f"{creds}\n"
|
||||
"Legacy OAuth is not serving these yet — restart Home Assistant "
|
||||
"when it asks you to, to activate them."
|
||||
)
|
||||
return f"{creds}\n{common['oauth_not_serving']}"
|
||||
|
||||
@@ -20,11 +20,12 @@ DOMAIN = "ha_mcp_tools"
|
||||
|
||||
# Component version, kept in lockstep with ``manifest.json``'s ``version``.
|
||||
# ``ha_mcp_tools/info`` reports this so the server can display/debug the running
|
||||
# component build; ``TestManifestVersionParity`` pins the two together so a
|
||||
# manifest bump that forgets this constant (or vice-versa) fails in CI. The
|
||||
# component build; ``TestInfo::test_manifest_version_parity`` pins the two
|
||||
# together so a manifest bump that forgets this constant (or vice-versa) fails
|
||||
# in CI. The
|
||||
# capability negotiation — not this version — gates each WS command (see
|
||||
# ``websocket_api.CAPABILITIES``).
|
||||
COMPONENT_VERSION = "1.2.3"
|
||||
COMPONENT_VERSION = "1.3.0"
|
||||
|
||||
# Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value
|
||||
# means "tools" so the pre-existing services entry keeps working across the
|
||||
@@ -96,7 +97,11 @@ ALLOWED_YAML_CONFIG_FILES = ["configuration.yaml"]
|
||||
|
||||
# Top-level YAML keys allowed for editing in any allowed file
|
||||
# (configuration.yaml or packages/*.yaml).
|
||||
# ONLY keys that have no UI/API alternative belong here.
|
||||
# The bar is "YAML is a legitimate way to manage this key", not "this key
|
||||
# has no UI alternative": template, utility_meter and group do have helper
|
||||
# equivalents and stay allowed for git-managed YAML configs (the caller
|
||||
# attaches a routing warning instead – see _HELPER_EQUIVALENT_KEYS in
|
||||
# src/ha_mcp/tools/tools_yaml_config.py).
|
||||
# Keys manageable via ha_config_set_helper (input_*, counter, timer, schedule)
|
||||
# are intentionally excluded. automation/script/scene live in
|
||||
# PACKAGES_ONLY_YAML_KEYS below — they have storage-mode equivalents
|
||||
@@ -143,6 +148,51 @@ PACKAGES_ONLY_YAML_KEYS = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
# Top-level YAML keys an operator can never unlock (#1887).
|
||||
# The operator-configurable extra-key list (ha-mcp's "extra YAML write
|
||||
# keys" setting) is additive on top of ALLOWED_YAML_KEYS, so this floor
|
||||
# is what keeps that setting from reaching HA's own trust boundary. It is
|
||||
# checked before every single-key allowlist branch and is deliberately NOT
|
||||
# operator-extendable – otherwise the same trust question just reopens
|
||||
# one level up. Scope note: it guards the per-key merge path only.
|
||||
# ``action="replace_file"`` returns before key validation runs at all, so a
|
||||
# whole-file rewrite of configuration.yaml can still contain these keys –
|
||||
# pre-existing behaviour, and the reason this is a floor under the extra-key
|
||||
# setting rather than a general "these keys are unwritable" guarantee.
|
||||
#
|
||||
# The bar is not "powerful": command_line, shell_command and rest are
|
||||
# already allowed above, so command execution and outbound HTTP are
|
||||
# accepted surface. The bar is "redefines authentication, escalates the
|
||||
# write surface itself, or can lock the user out" – unrecoverable in a
|
||||
# way a broken sensor is not. Verified against home-assistant/core:
|
||||
# homeassistant: CORE_CONFIG_SCHEMA (homeassistant/core_config.py) takes
|
||||
# auth_providers / auth_mfa_modules (how the instance authenticates)
|
||||
# and packages (which folder is loaded as packages – a write here
|
||||
# would redirect the very surface this feature is bounded by).
|
||||
# http: takes trusted_proxies + use_x_forwarded_for (a spoofable
|
||||
# X-Forwarded-For becomes an auth bypass), cors_allowed_origins, and
|
||||
# ip_ban_enabled / login_attempts_threshold (brute-force protection).
|
||||
# frontend: takes extra_module_url, JavaScript modules loaded into the
|
||||
# authenticated dashboard – a stored-XSS foothold with access to the
|
||||
# instance and its tokens.
|
||||
# lovelace: takes resources (url + type: module), loaded whenever
|
||||
# resource_mode resolves to yaml. That is the same JS-into-an-
|
||||
# authenticated-dashboard primitive as frontend: extra_module_url, so
|
||||
# denying one while allowing the other would be a floor contradicting
|
||||
# its own rationale. Only the bare key is denied; the validated
|
||||
# lovelace.dashboards.<url_path> shape is a different branch and stays
|
||||
# available for YAML-mode dashboard management.
|
||||
# auth and api are absent on purpose: both have an empty CONFIG_SCHEMA in
|
||||
# core, so there is no sub-key to restrict.
|
||||
YAML_KEY_DENYLIST = frozenset(
|
||||
{
|
||||
"homeassistant",
|
||||
"http",
|
||||
"frontend",
|
||||
"lovelace",
|
||||
}
|
||||
)
|
||||
|
||||
# Post-edit action required for each YAML key.
|
||||
# template, mqtt, group, automation, script, and scene have first-party
|
||||
# reload services in HA core. All others require a full HA restart.
|
||||
|
||||
@@ -727,6 +727,10 @@ async def _async_update_held_by_component(
|
||||
|
||||
try:
|
||||
integration = await async_get_integration(hass, DOMAIN)
|
||||
if integration.version is None:
|
||||
# None rather than an exception; "None" would then reach
|
||||
# AwesomeVersion and compare as an ordinary string.
|
||||
raise ValueError("the manifest carries no version")
|
||||
running = str(integration.version)
|
||||
except Exception:
|
||||
# Same wide loader surface as _async_check_component_compat: advisory
|
||||
@@ -884,6 +888,10 @@ async def _async_check_component_compat(
|
||||
|
||||
try:
|
||||
integration = await async_get_integration(hass, DOMAIN)
|
||||
if integration.version is None:
|
||||
# None rather than an exception; "None" would then reach
|
||||
# AwesomeVersion and compare as an ordinary string.
|
||||
raise ValueError("the manifest carries no version")
|
||||
own = str(integration.version)
|
||||
except Exception:
|
||||
# The loader legitimately raises a wide, varied surface
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"requirements": [
|
||||
"ruamel.yaml>=0.18.0"
|
||||
],
|
||||
"version": "1.2.3"
|
||||
"version": "1.3.0"
|
||||
}
|
||||
|
||||
@@ -100,8 +100,15 @@ _STRIPPED_REQUEST_HEADERS = frozenset(
|
||||
# instead of a mislabeled JSON blob. ``text/html`` and friends stay coerced.
|
||||
_ALLOWED_CONTENT_TYPES = ("application/json", "text/event-stream", "text/plain")
|
||||
|
||||
# Long timeout for streamed MCP responses (matches mcp_proxy).
|
||||
_CLIENT_TIMEOUT = aiohttp.ClientTimeout(total=300, sock_connect=10, sock_read=300)
|
||||
# Timeout for streamed MCP responses (matches mcp_proxy). Deliberately NO
|
||||
# wall-clock ``total``: an MCP response stream is long-lived by design (the
|
||||
# upcoming spec's ``subscriptions/listen`` holds one open indefinitely), so a
|
||||
# ``total`` bound would cut a *healthy* stream and force the client to
|
||||
# re-subscribe. ``sock_read`` bounds a *dead* one instead — idle detection, not
|
||||
# elapsed time. ``connect`` stays finite: it covers connection-POOL acquisition
|
||||
# (not just the TCP connect ``sock_connect`` bounds), so a pool exhausted by
|
||||
# long-lived streams fails a new request in 30 s instead of hanging it forever.
|
||||
_CLIENT_TIMEOUT = aiohttp.ClientTimeout(connect=30, sock_connect=10, sock_read=300)
|
||||
|
||||
# TOP-LEVEL hass.data flag recording that the ha_auth discovery views are bound
|
||||
# for this HA session. Deliberately NOT under DOMAIN so it survives
|
||||
@@ -291,11 +298,31 @@ def _protected_resource_document(webhook_id: str, base: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def oauth_issuer(base: str) -> str:
|
||||
"""Issuer identifier this component's OWN authorization servers advertise.
|
||||
|
||||
Single source for the ``issuer`` field of the legacy and none-mode documents
|
||||
below and for the RFC 9207 ``iss`` authorization-response parameter that
|
||||
:mod:`oauth_legacy` / :mod:`oauth_autoapprove` put on their redirects — RFC
|
||||
9207 §2 requires the redirect's ``iss`` to equal the advertised issuer
|
||||
exactly.
|
||||
"""
|
||||
return f"{base}{OAUTH_BASE}"
|
||||
|
||||
|
||||
def issuer_for_request(request: web.Request) -> str:
|
||||
""":func:`oauth_issuer` for the public base URL ``request`` resolves to."""
|
||||
return oauth_issuer(_build_base_url(request))
|
||||
|
||||
|
||||
def _legacy_authorization_server_document(base: str) -> dict[str, Any]:
|
||||
"""RFC 8414 authorization-server metadata for legacy mode's own root
|
||||
``/authorize`` + ``/token`` views (see :mod:`oauth_legacy`)."""
|
||||
return {
|
||||
"issuer": f"{base}{OAUTH_BASE}",
|
||||
"issuer": oauth_issuer(base),
|
||||
# RFC 9207 §3: authorization responses carry ``iss`` (oauth_legacy's
|
||||
# redirects); omission reads as "not supported" to discovery clients.
|
||||
"authorization_response_iss_parameter_supported": True,
|
||||
"authorization_endpoint": f"{base}{AUTHORIZE_PATH}",
|
||||
"token_endpoint": f"{base}{TOKEN_PATH}",
|
||||
"response_types_supported": ["code"],
|
||||
@@ -323,7 +350,10 @@ def _none_mode_authorization_server_document(base: str) -> dict[str, Any]:
|
||||
``authorization_code`` is advertised.
|
||||
"""
|
||||
return {
|
||||
"issuer": f"{base}{OAUTH_BASE}",
|
||||
"issuer": oauth_issuer(base),
|
||||
# RFC 9207 §3: authorization responses carry ``iss`` (the auto-approve
|
||||
# redirects); omission reads as "not supported" to discovery clients.
|
||||
"authorization_response_iss_parameter_supported": True,
|
||||
"authorization_endpoint": f"{base}{OAUTH_BASE}/authorize",
|
||||
"token_endpoint": f"{base}{OAUTH_BASE}/token",
|
||||
"response_types_supported": ["code"],
|
||||
|
||||
@@ -59,6 +59,7 @@ from .oauth_legacy import (
|
||||
ACCESS_TOKEN_TTL,
|
||||
PKCECodeStore,
|
||||
_is_valid_redirect_uri,
|
||||
_issuer_for,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -238,14 +239,19 @@ class AutoApproveAuthorizeView(HomeAssistantView):
|
||||
if not _is_valid_autoapprove_redirect(redirect_uri):
|
||||
return _json_error("invalid_request", 400, "invalid redirect_uri")
|
||||
|
||||
# RFC 9207: every authorization response — success or error — names the
|
||||
# issuer that produced it, so a client registered with several
|
||||
# authorization servers cannot be fed a response minted by another one.
|
||||
iss = _issuer_for(request)
|
||||
|
||||
code = provider.issue_code(redirect_uri, code_challenge)
|
||||
if code is None:
|
||||
# Pending-code store at capacity (abuse guard) — surface per
|
||||
# RFC 6749 §4.1.2.1 instead of a silent failure.
|
||||
return _redirect_with(
|
||||
redirect_uri, error="temporarily_unavailable", state=state
|
||||
redirect_uri, error="temporarily_unavailable", state=state, iss=iss
|
||||
)
|
||||
redirect_params = {"code": code}
|
||||
redirect_params = {"code": code, "iss": iss}
|
||||
if state:
|
||||
redirect_params["state"] = state
|
||||
return _redirect_with(redirect_uri, **redirect_params)
|
||||
|
||||
@@ -292,6 +292,19 @@ def _live_auth_mode(hass: HomeAssistant) -> str | None:
|
||||
return active_auth_mode(hass)
|
||||
|
||||
|
||||
def _issuer_for(request: web.Request) -> str:
|
||||
"""The issuer identifier this mode advertises for ``request``.
|
||||
|
||||
RFC 9207 §2 requires the ``iss`` authorization-response parameter to equal
|
||||
the advertised ``issuer`` exactly, so it comes from the same helper that
|
||||
fills the discovery document's field. Deferred import for the module-cycle
|
||||
reason documented on :func:`_live_auth_mode`.
|
||||
"""
|
||||
from .mcp_webhook import issuer_for_request
|
||||
|
||||
return issuer_for_request(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Small helpers (ported from the add-on's oauth.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -713,8 +726,15 @@ class AuthorizeView(HomeAssistantView):
|
||||
if err is not None:
|
||||
return err
|
||||
|
||||
# RFC 9207: every authorization response — success or error — names the
|
||||
# issuer that produced it, so a client registered with several
|
||||
# authorization servers cannot be fed a response minted by another one.
|
||||
iss = _issuer_for(request)
|
||||
|
||||
if action == "deny":
|
||||
return self._redirect_with(redirect_uri, error="access_denied", state=state)
|
||||
return self._redirect_with(
|
||||
redirect_uri, error="access_denied", state=state, iss=iss
|
||||
)
|
||||
if action != "approve":
|
||||
return _text_error(400, "invalid action")
|
||||
|
||||
@@ -723,9 +743,9 @@ class AuthorizeView(HomeAssistantView):
|
||||
# Pending-code store at cap → signal back per RFC 6749 §4.1.2.1
|
||||
# instead of silently failing.
|
||||
return self._redirect_with(
|
||||
redirect_uri, error="temporarily_unavailable", state=state
|
||||
redirect_uri, error="temporarily_unavailable", state=state, iss=iss
|
||||
)
|
||||
return self._redirect_with(redirect_uri, code=code, state=state)
|
||||
return self._redirect_with(redirect_uri, code=code, state=state, iss=iss)
|
||||
|
||||
def _validate_authorize_params(
|
||||
self,
|
||||
|
||||
@@ -210,3 +210,33 @@ set_allowed_paths:
|
||||
example: '["pyscript", "python_scripts"]'
|
||||
selector:
|
||||
object:
|
||||
|
||||
get_extra_yaml_keys:
|
||||
name: Get Extra YAML Write Keys (Internal)
|
||||
description: >-
|
||||
Internal service used by the ha-mcp server and the integration's own
|
||||
options flow. Returns the component-configured extra top-level YAML write
|
||||
keys and the non-overridable deny floor. Restricted to the ha-mcp server
|
||||
(requires the `_ha_mcp_token`) and admin auth. Not intended for direct
|
||||
invocation from automations or scripts.
|
||||
fields: {}
|
||||
|
||||
set_extra_yaml_keys:
|
||||
name: Set Extra YAML Write Keys (Internal)
|
||||
description: >-
|
||||
Internal service used by the ha-mcp server settings UI and the
|
||||
integration's own options flow. Replaces the component-configured extra
|
||||
top-level keys that ha_config_set_yaml may write in addition to the
|
||||
built-in ones. Blank entries and keys on the non-overridable deny floor
|
||||
(e.g. homeassistant, http, frontend, lovelace) are dropped. Restricted to
|
||||
the ha-mcp server (requires the `_ha_mcp_token`) and admin auth.
|
||||
fields:
|
||||
keys:
|
||||
name: Keys
|
||||
description: >-
|
||||
Top-level YAML keys, each additionally writable by ha_config_set_yaml
|
||||
on this install (e.g. "alert2"). Replaces the current list.
|
||||
required: false
|
||||
example: '["alert2"]'
|
||||
selector:
|
||||
object:
|
||||
|
||||
@@ -27,7 +27,15 @@
|
||||
"step": {
|
||||
"tools_info": {
|
||||
"title": "HA-MCP File & YAML Tools",
|
||||
"description": "This entry provides the privileged file and YAML editing services used by ha-mcp's opt-in file/YAML tools. There is nothing to configure here yet. Which directories the file tools may access is managed from the ha-mcp server's own settings (its Settings UI / allowed-paths), not here. Per-entry options may appear on this screen in a future release."
|
||||
"description": "Configure the privileged file and YAML editing services used by ha-mcp's opt-in file/YAML tools. These settings also appear in the ha-mcp server's own settings UI and apply live, with no restart. A non-overridable deny floor still blocks sensitive paths (e.g. .storage) and trust-boundary keys (homeassistant, http, frontend, lovelace).",
|
||||
"data": {
|
||||
"allowed_dirs": "Extra file directories",
|
||||
"extra_yaml_keys": "Extra YAML write keys"
|
||||
},
|
||||
"data_description": {
|
||||
"allowed_dirs": "Directories relative to the config directory, each granted read and write (e.g. pyscript). Traversal and out-of-config entries are dropped.",
|
||||
"extra_yaml_keys": "Top-level keys ha_config_set_yaml may write in addition to the built-in ones, for YAML-first integrations on this install (e.g. alert2). Trust-boundary keys are dropped."
|
||||
}
|
||||
},
|
||||
"init": {
|
||||
"title": "HA-MCP Server",
|
||||
@@ -131,5 +139,23 @@
|
||||
"name": "Update"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"connect_direct_access": "Direct access from the Home Assistant machine: {url}",
|
||||
"connect_local_lan": "Local/LAN (when Network access is \"Local network\"): {url}",
|
||||
"connect_remote_url": "Remote connect URL: {url}",
|
||||
"connect_urls_label": "Connect URL(s):",
|
||||
"connect_urls_pending": "The connect URLs appear here (and in the Home Assistant log) once the server has started.",
|
||||
"connect_webhook_disabled": "Remote access via webhook is disabled (local-only mode).",
|
||||
"oauth_creds_pending": "The Client ID and Client Secret appear here once the server has started.",
|
||||
"oauth_not_serving": "Legacy OAuth is not serving these yet — restart Home Assistant when it asks you to, to activate them.",
|
||||
"oauth_select_legacy_mode": "Set Authentication mode to legacy OAuth above and save to generate a Client ID and Client Secret.",
|
||||
"panel_hint": "Open the [HA-MCP settings panel](/ha-mcp) for tool management and server settings.",
|
||||
"tools_module_installed": "Beta/advanced file & YAML tools module (optional): Installed",
|
||||
"tools_module_not_installed": "Beta/advanced file & YAML tools module (optional): Not installed — press \"Add entry\" on this integration's page and choose \"HA-MCP File & YAML Tools\" to add it",
|
||||
"tools_module_not_loaded": "Beta/advanced file & YAML tools module (optional): Installed but not loaded — enable or reload the \"HA-MCP File & YAML Tools\" entry on this integration's page",
|
||||
"version_line": "Component {component_version} - Server ha-mcp {server_version} ({channel} channel)",
|
||||
"version_not_installed": "not installed yet",
|
||||
"version_unknown": "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,15 @@
|
||||
"step": {
|
||||
"tools_info": {
|
||||
"title": "HA-MCP File & YAML Tools",
|
||||
"description": "Dieser Eintrag stellt die privilegierten Datei- und YAML-Bearbeitungsdienste bereit, die von den opt-in Datei/YAML-Tools von ha-mcp verwendet werden. Hier gibt es noch nichts zu konfigurieren. Welche Verzeichnisse die Datei-Tools nutzen dürfen, wird in den eigenen Einstellungen des ha-mcp-Servers (Settings UI / allowed-paths) verwaltet, nicht hier. Pro-Eintrag-Optionen könnten in einem zukünftigen Release auf diesem Bildschirm erscheinen."
|
||||
"description": "Konfiguriere die privilegierten Datei- und YAML-Bearbeitungsdienste, die von den opt-in Datei/YAML-Tools von ha-mcp verwendet werden. Diese Einstellungen erscheinen auch in der eigenen Einstellungs-UI des ha-mcp-Servers und werden live wirksam, ohne Neustart. Eine nicht überschreibbare Deny-Grenze blockiert weiterhin sensible Pfade (z. B. .storage) und Vertrauensgrenzen-Keys (homeassistant, http, frontend, lovelace).",
|
||||
"data": {
|
||||
"allowed_dirs": "Zusätzliche Datei-Verzeichnisse",
|
||||
"extra_yaml_keys": "Zusätzliche YAML-Schreib-Keys"
|
||||
},
|
||||
"data_description": {
|
||||
"allowed_dirs": "Verzeichnisse relativ zum Konfigurationsverzeichnis, jeweils mit Lese- und Schreibzugriff (z. B. pyscript). Traversal- und Einträge außerhalb des Konfig-Verzeichnisses werden verworfen.",
|
||||
"extra_yaml_keys": "Top-Level-Keys, die ha_config_set_yaml zusätzlich zu den eingebauten schreiben darf, für YAML-first-Integrationen auf dieser Installation (z. B. alert2). Vertrauensgrenzen-Keys werden verworfen."
|
||||
}
|
||||
},
|
||||
"init": {
|
||||
"title": "HA-MCP Server",
|
||||
@@ -131,5 +139,23 @@
|
||||
"name": "Update"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"connect_direct_access": "Direkter Zugriff von der Home Assistant Maschine: {url}",
|
||||
"connect_local_lan": "Lokal/LAN (wenn Netzwerkzugriff auf „Local network“ steht): {url}",
|
||||
"connect_remote_url": "Remote-Verbindungs-URL: {url}",
|
||||
"connect_urls_label": "Verbindungs-URL(s):",
|
||||
"connect_urls_pending": "Die Verbindungs-URLs erscheinen hier (und im Home Assistant Log), sobald der Server gestartet ist.",
|
||||
"connect_webhook_disabled": "Remote-Zugriff über Webhook ist deaktiviert (nur-lokal-Modus).",
|
||||
"oauth_creds_pending": "Client ID und Client Secret erscheinen hier, sobald der Server gestartet ist.",
|
||||
"oauth_not_serving": "Legacy OAuth bedient diese noch nicht — starte Home Assistant neu, wenn du dazu aufgefordert wirst, um sie zu aktivieren.",
|
||||
"oauth_select_legacy_mode": "Setze den Authentifizierungsmodus oben auf Legacy OAuth und speichere, um eine Client ID und ein Client Secret zu erzeugen.",
|
||||
"panel_hint": "Öffne das [HA-MCP Einstellungs-Panel](/ha-mcp) für Tool-Verwaltung und Servereinstellungen.",
|
||||
"tools_module_installed": "Beta-/Erweitert-Modul für Datei- & YAML-Tools (optional): Installiert",
|
||||
"tools_module_not_installed": "Beta-/Erweitert-Modul für Datei- & YAML-Tools (optional): Nicht installiert — klicke auf „Eintrag hinzufügen“ auf der Seite dieser Integration und wähle „HA-MCP File & YAML Tools“",
|
||||
"tools_module_not_loaded": "Beta-/Erweitert-Modul für Datei- & YAML-Tools (optional): Installiert, aber nicht geladen — aktiviere oder lade den Eintrag „HA-MCP File & YAML Tools“ auf der Seite dieser Integration neu",
|
||||
"version_line": "Komponente {component_version} - Server ha-mcp {server_version} ({channel}-Kanal)",
|
||||
"version_not_installed": "noch nicht installiert",
|
||||
"version_unknown": "unbekannt"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,15 @@
|
||||
"step": {
|
||||
"tools_info": {
|
||||
"title": "HA-MCP File & YAML Tools",
|
||||
"description": "This entry provides the privileged file and YAML editing services used by ha-mcp's opt-in file/YAML tools. There is nothing to configure here yet. Which directories the file tools may access is managed from the ha-mcp server's own settings (its Settings UI / allowed-paths), not here. Per-entry options may appear on this screen in a future release."
|
||||
"description": "Configure the privileged file and YAML editing services used by ha-mcp's opt-in file/YAML tools. These settings also appear in the ha-mcp server's own settings UI and apply live, with no restart. A non-overridable deny floor still blocks sensitive paths (e.g. .storage) and trust-boundary keys (homeassistant, http, frontend, lovelace).",
|
||||
"data": {
|
||||
"allowed_dirs": "Extra file directories",
|
||||
"extra_yaml_keys": "Extra YAML write keys"
|
||||
},
|
||||
"data_description": {
|
||||
"allowed_dirs": "Directories relative to the config directory, each granted read and write (e.g. pyscript). Traversal and out-of-config entries are dropped.",
|
||||
"extra_yaml_keys": "Top-level keys ha_config_set_yaml may write in addition to the built-in ones, for YAML-first integrations on this install (e.g. alert2). Trust-boundary keys are dropped."
|
||||
}
|
||||
},
|
||||
"init": {
|
||||
"title": "HA-MCP Server",
|
||||
@@ -131,5 +139,23 @@
|
||||
"name": "Update"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"connect_direct_access": "Direct access from the Home Assistant machine: {url}",
|
||||
"connect_local_lan": "Local/LAN (when Network access is \"Local network\"): {url}",
|
||||
"connect_remote_url": "Remote connect URL: {url}",
|
||||
"connect_urls_label": "Connect URL(s):",
|
||||
"connect_urls_pending": "The connect URLs appear here (and in the Home Assistant log) once the server has started.",
|
||||
"connect_webhook_disabled": "Remote access via webhook is disabled (local-only mode).",
|
||||
"oauth_creds_pending": "The Client ID and Client Secret appear here once the server has started.",
|
||||
"oauth_not_serving": "Legacy OAuth is not serving these yet — restart Home Assistant when it asks you to, to activate them.",
|
||||
"oauth_select_legacy_mode": "Set Authentication mode to legacy OAuth above and save to generate a Client ID and Client Secret.",
|
||||
"panel_hint": "Open the [HA-MCP settings panel](/ha-mcp) for tool management and server settings.",
|
||||
"tools_module_installed": "Beta/advanced file & YAML tools module (optional): Installed",
|
||||
"tools_module_not_installed": "Beta/advanced file & YAML tools module (optional): Not installed — press \"Add entry\" on this integration's page and choose \"HA-MCP File & YAML Tools\" to add it",
|
||||
"tools_module_not_loaded": "Beta/advanced file & YAML tools module (optional): Installed but not loaded — enable or reload the \"HA-MCP File & YAML Tools\" entry on this integration's page",
|
||||
"version_line": "Component {component_version} - Server ha-mcp {server_version} ({channel} channel)",
|
||||
"version_not_installed": "not installed yet",
|
||||
"version_unknown": "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Componente personalizado HA-MCP",
|
||||
"description": "Elige qué quieres añadir. **Servidor HA-MCP** ejecuta el servidor ha-mcp completo dentro de Home Assistant y lo expone a través de un webhook de Home Assistant: es la instalación que quiere la mayoría de la gente. Es un servidor autónomo y un sustituto completo de cualquier otro método de instalación (complemento, Docker, uvx/PyPI, stdio); si lo usas, no uses además uno de esos. **Herramientas de archivos y YAML de HA-MCP** añade los servicios privilegiados de edición de archivos y YAML, necesarios solo si activas las herramientas opcionales de archivos/YAML de ha-mcp. Si tu servidor ha-mcp ya se ejecuta en otro sitio (complemento, Docker, uvx), no necesitas la entrada del servidor: añade solo esta entrada de archivos y YAML, y solo si usas esas herramientas. Funciona con cualquier tipo de servidor y se puede añadir más adelante en cualquier momento.",
|
||||
"menu_options": {
|
||||
"server": "Servidor HA-MCP (recomendado)",
|
||||
"tools": "Herramientas de archivos y YAML de HA-MCP (opcional)"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"title": "Herramientas de archivos y YAML de HA-MCP",
|
||||
"description": "Configura los servicios privilegiados de archivos y de configuración YAML. Solo hacen falta si activas las herramientas opcionales de edición de archivos/YAML de ha-mcp (indicadores de función, desactivados de forma predeterminada); esto vale para cualquier tipo de servidor, incluido el servidor HA-MCP integrado en el proceso. Puedes añadir o quitar esta entrada en cualquier momento."
|
||||
},
|
||||
"server": {
|
||||
"title": "Servidor HA-MCP",
|
||||
"description": "Esto ejecuta el servidor ha-mcp completo dentro de Home Assistant y lo expone de forma remota a través de un webhook de Home Assistant (accesible mediante Nabu Casa o cualquier proxy inverso). Es un servidor autónomo y un sustituto completo de los métodos de instalación por complemento, Docker, uvx/PyPI y stdio: no lo ejecutes junto a otro servidor ha-mcp. Pulsa **Enviar** para iniciarlo; después puedes cambiar el puerto, la vinculación y la autenticación en las opciones de la integración."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Esta entrada ya está configurada.",
|
||||
"unsupported_home_assistant": "El servidor HA-MCP integrado en el proceso requiere Home Assistant {required} o posterior, pero esta instancia ejecuta {installed}. Aun así puedes usar la entrada HA-MCP File & YAML Tools de este componente con un servidor ha-mcp externo que se ejecute como complemento o contenedor Docker."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"tools_info": {
|
||||
"title": "Herramientas de archivos y YAML de HA-MCP",
|
||||
"description": "Configura los servicios privilegiados de edición de archivos y YAML que usan las herramientas opcionales de archivos/YAML de ha-mcp. Estos ajustes aparecen también en la propia interfaz de ajustes del servidor ha-mcp y se aplican en caliente, sin reiniciar. Un mínimo de denegación no anulable sigue bloqueando rutas sensibles (p. ej. .storage) y claves de la frontera de confianza (homeassistant, http, frontend, lovelace).",
|
||||
"data": {
|
||||
"allowed_dirs": "Directorios de archivos adicionales",
|
||||
"extra_yaml_keys": "Claves YAML de escritura adicionales"
|
||||
},
|
||||
"data_description": {
|
||||
"allowed_dirs": "Directorios relativos al directorio de configuración, cada uno con permiso de lectura y escritura (p. ej. pyscript). Se descartan los recorridos de ruta y las entradas fuera de la configuración.",
|
||||
"extra_yaml_keys": "Claves de primer nivel que ha_config_set_yaml puede escribir además de las integradas, para integraciones basadas en YAML de esta instalación (p. ej. alert2). Las claves de la frontera de confianza se descartan."
|
||||
}
|
||||
},
|
||||
"init": {
|
||||
"title": "Servidor HA-MCP",
|
||||
"description": "Configura el servidor HA-MCP. {panel_hint}Los cambios de aquí se aplican al guardar.\n\n{versions}\n\n{connect_url}\n\n{oauth_creds}",
|
||||
"data": {
|
||||
"channel": "Canal de versiones",
|
||||
"auto_update": "Actualizaciones automáticas del servidor",
|
||||
"server_port": "Puerto de escucha del servidor MCP",
|
||||
"bind_host": "Acceso desde la red",
|
||||
"webhook_auth": "Modo de autenticación",
|
||||
"oauth_client_id_override": "OAuth heredado: Client ID personalizado (opcional)",
|
||||
"oauth_client_secret_override": "OAuth heredado: Client Secret personalizado (opcional)",
|
||||
"oauth_regenerate": "OAuth heredado: regenerar ahora el Client ID y el Client Secret",
|
||||
"pip_spec": "Desarrollo: paquete ha-mcp alternativo",
|
||||
"server_url": "URL de Home Assistant (avanzado)",
|
||||
"external_url": "URL externa (opcional)",
|
||||
"webhook_id_override": "Secreto de webhook personalizado (opcional)",
|
||||
"secret_path_override": "Ruta de acceso directo personalizada (opcional)",
|
||||
"regenerate_secrets": "Regenerar ahora los secretos de conexión",
|
||||
"enable_webhook": "Acceso remoto mediante webhook",
|
||||
"enable_llm_api": "API de LLM para agentes de conversación",
|
||||
"llm_api_exposure": "Exposición de herramientas al agente de conversación",
|
||||
"enable_startup_notification": "Notificación de inicio",
|
||||
"enable_sidebar_panel": "Panel de ajustes en la barra lateral"
|
||||
},
|
||||
"data_description": {
|
||||
"channel": "Estable instala la última versión estable; Desarrollo instala la compilación de desarrollo más reciente. Con las actualizaciones automáticas activadas, una recarga o un reinicio, más una comprobación periódica, instalan la compilación más reciente del canal elegido. El paquete alternativo de desarrollo de abajo tiene prioridad y desactiva las actualizaciones automáticas.",
|
||||
"auto_update": "Cuando está activado, la versión más reciente del canal elegido se instala automáticamente: en una recarga o reinicio y mediante una comprobación periódica. Cuando está desactivado, el servidor se queda en la versión instalada hasta que lo vuelvas a activar. Esto controla únicamente el paquete del servidor ha-mcp; las actualizaciones del propio componente personalizado HA-MCP siguen llegando por HACS.",
|
||||
"server_port": "El puerto en el que escucha este servidor. El complemento ha-mcp usa el 9583, así que aquí el valor predeterminado es 9584 para que ambos puedan convivir; si no usas el complemento, sirve cualquier puerto libre.",
|
||||
"bind_host": "Quién puede conectarse directamente al puerto del servidor MCP. El valor predeterminado coincide con el del complemento: accesible en tu red local, con la ruta secreta como credencial. Elige loopback para permitir solo conexiones desde la propia máquina de Home Assistant; la URL del webhook y el panel de la barra lateral funcionan en ambos casos.",
|
||||
"webhook_auth": "Cómo se identifican los clientes MCP en la URL del webhook. Con la URL secreta, el propio enlace es la credencial. Con el inicio de sesión de Home Assistant, clientes como claude.ai entran con una cuenta de administrador de Home Assistant (OAuth). Con OAuth heredado, esta integración emite su propio Client ID y Client Secret para pegarlos en clientes que los exijan, como Google Gemini Spark; activarlo o desactivarlo requiere reiniciar Home Assistant.",
|
||||
"oauth_client_id_override": "Sustituye el Client ID de OAuth heredado generado automáticamente. Déjalo vacío para conservar el actual. Solo se usa mientras el modo de autenticación esté en OAuth heredado.",
|
||||
"oauth_client_secret_override": "Sustituye el Client Secret de OAuth heredado generado automáticamente. Déjalo vacío para conservar el actual. Solo se usa mientras el modo de autenticación esté en OAuth heredado.",
|
||||
"oauth_regenerate": "Acción puntual: genera un Client ID y un Client Secret nuevos para el modo OAuth heredado. Solo surte efecto tras el reinicio de Home Assistant que pide el aviso de reparación; hasta que reinicies siguen funcionando el Client ID y el Client Secret anteriores, y no los nuevos. También vacía los dos campos de sustitución de arriba.",
|
||||
"pip_spec": "Déjalo vacío. Solo sirve para probar una compilación concreta de ha-mcp (por ejemplo, fijar una versión preliminar); tiene prioridad sobre el canal de versiones y desactiva las actualizaciones automáticas hasta que lo vacíes.",
|
||||
"server_url": "La URL que usa el servidor integrado para llegar a tu Home Assistant (normalmente esta misma instancia). Déjala vacía para deducirla del puerto y los ajustes SSL de esta instancia; indica un valor solo si el servidor necesita otra ruta.",
|
||||
"external_url": "Se muestra como URL de conexión principal: úsala cuando Home Assistant esté detrás de tu propio dominio o proxy inverso. Introduce la dirección base completa, incluido el esquema. Debe apuntar directamente a Home Assistant —abrirla en un navegador debería llevar a tu página de inicio de sesión de HA— y no debe contener un puerto como :8123 (ni ningún otro), o los clientes MCP remotos no podrán llegar. Déjala vacía para usar automáticamente Nabu Casa o la dirección local.",
|
||||
"webhook_id_override": "Sustituye el secreto aleatorio del webhook en la URL de conexión (/api/webhook/...). La URL es la credencial: usa un valor largo y difícil de adivinar. Déjalo vacío para conservar el actual.",
|
||||
"secret_path_override": "Sustituye la ruta aleatoria que se usa para el acceso directo en el puerto del servidor. Misma regla: la ruta es la credencial. Déjalo vacío para conservar la actual.",
|
||||
"regenerate_secrets": "Acción puntual: genera un secreto de webhook y una ruta de acceso directo aleatorios nuevos, invalidando de inmediato las URL de conexión antiguas. También vacía los dos campos de sustitución de arriba.",
|
||||
"enable_webhook": "Desactívalo para el modo solo local: el webhook de Home Assistant no se registra en absoluto, así que nada —Nabu Casa incluida— puede llegar al servidor a través de Home Assistant. El puerto directo del servidor y el panel de la barra lateral siguen funcionando.",
|
||||
"enable_llm_api": "Ofrece el conjunto completo de herramientas a los agentes de conversación de Home Assistant (OpenAI, Google, Ollama, ...): mientras esté activado, los agentes pueden seleccionar 'HA-MCP Server' en Controlar Home Assistant y manejar las herramientas desde el chat y la voz de Assist. Activarlo solo lo hace seleccionable: no se expone nada hasta que lo elijas en un agente. Guía de uso: {llm_api_docs_url}",
|
||||
"llm_api_exposure": "Forma del conjunto de herramientas que se ofrece a los agentes de conversación. La búsqueda de herramientas (predeterminada) mantiene pequeño el contexto del agente: una API compacta con herramientas fijadas más metaherramientas de búsqueda y ejecución. El catálogo completo lista directamente todas las herramientas expuestas, mejor para modelos con contexto grande. Ambos registra las dos opciones en paralelo para que cada agente elija la suya en Controlar Home Assistant. La exposición por herramienta se gestiona en el panel de ajustes de HA-MCP; detalles: {llm_api_docs_url}",
|
||||
"enable_startup_notification": "Muestra una notificación cada vez que arranca el servidor, con enlaces a las superficies de ajustes reservadas a administradores. Desactívalo para arrancar en silencio: las URL de conexión siguen apareciendo en el registro de Home Assistant.",
|
||||
"enable_sidebar_panel": "Muestra el panel de ajustes de HA-MCP en la barra lateral (solo para administradores). Desactívalo para quitar la entrada de la barra lateral: las opciones del servidor siguen disponibles en esta pantalla."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"server_start_failed": {
|
||||
"title": "El servidor HA-MCP integrado en el proceso no ha podido arrancar",
|
||||
"description": "El servidor HA-MCP integrado en el proceso no se ha podido iniciar dentro de Home Assistant:\n\n{detail}\n\nRevisa los registros de Home Assistant y vuelve a cargar la integración (o corrige el problema de fondo y recárgala) para reintentarlo."
|
||||
},
|
||||
"server_package_install_failed": {
|
||||
"title": "No se ha podido instalar el paquete del servidor HA-MCP integrado en el proceso",
|
||||
"description": "Ha fallado la instalación del paquete ha-mcp para el servidor integrado en el proceso:\n\n{detail}\n\nResuelve el problema de compatibilidad o de instalación descrito arriba y vuelve a cargar la integración para reintentarlo."
|
||||
},
|
||||
"component_outdated": {
|
||||
"title": "Actualiza el componente personalizado HA-MCP mediante HACS",
|
||||
"description": "El servidor ha-mcp instalado requiere el componente personalizado HA-MCP {required} o posterior, pero tienes {installed}. Actualiza el componente mediante HACS (abre la entrada del componente personalizado HA-MCP y usa 'Actualizar información' si todavía no aparece ninguna actualización) y reinicia Home Assistant. Mientras tanto el servidor sigue funcionando, pero puede que algunas funciones nuevas no funcionen hasta que se actualice el componente."
|
||||
},
|
||||
"server_update_held": {
|
||||
"title": "Actualización del servidor HA-MCP a la espera de una actualización del componente",
|
||||
"description": "Está disponible el servidor ha-mcp {latest}, pero esa versión actualizó también el componente personalizado HA-MCP (a {shipped}; tú ejecutas {running}). Para evitar arrancar una versión del servidor con la que el componente en uso nunca se ha probado, la actualización automática del servidor queda en espera hasta que se actualice el componente.\n\nActualiza el componente mediante HACS (abre la entrada del componente personalizado HA-MCP y usa 'Actualizar información' si todavía no aparece ninguna actualización) y reinicia Home Assistant: después, la actualización del servidor se instala automáticamente. Para instalar la actualización del servidor de todos modos, pulsa Instalar en la entidad de actualización del servidor HA-MCP."
|
||||
},
|
||||
"legacy_hacs_source": {
|
||||
"title": "Componente instalado desde el repositorio antiguo",
|
||||
"description": "HACS está siguiendo el repositorio principal del servidor ha-mcp para este componente, así que aquí muestra los números de versión del servidor (7.x) y sus notas de la versión en lugar de los del propio componente (1.x). Las actualizaciones siguen funcionando, pero permanecen mal etiquetadas. Para arreglarlo: quita este repositorio de HACS (se conservan tus ajustes de integración y tus entradas de configuración), añade homeassistant-ai/ha-mcp-integration como repositorio personalizado, reinstala el componente desde ahí y reinicia Home Assistant."
|
||||
},
|
||||
"legacy_oauth_restart": {
|
||||
"title": "Reinicia Home Assistant para aplicar el cambio de OAuth heredado",
|
||||
"description": "El modo de autenticación OAuth heredado registra sus propios extremos web /authorize y /token, que Home Assistant solo puede enlazar o liberar en un reinicio completo, tanto si acabas de activar este modo como si lo has desactivado o has cambiado su Client ID o Client Secret. Reinicia Home Assistant (Ajustes - Sistema - Reiniciar) para aplicar el cambio; hasta entonces se mantiene el comportamiento anterior."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"server_channel": {
|
||||
"options": {
|
||||
"stable": "Estable (recomendado)",
|
||||
"dev": "Desarrollo (última compilación)"
|
||||
}
|
||||
},
|
||||
"server_webhook_auth": {
|
||||
"options": {
|
||||
"none": "URL de webhook secreta (predeterminada)",
|
||||
"ha_auth": "Iniciar sesión con Home Assistant (OAuth)",
|
||||
"legacy": "OAuth heredado (Client ID y Client Secret, para Google Gemini Spark)"
|
||||
}
|
||||
},
|
||||
"llm_api_exposure": {
|
||||
"options": {
|
||||
"tool_search": "Búsqueda de herramientas (compacta, predeterminada)",
|
||||
"full": "Catálogo completo",
|
||||
"both": "Ambas (elegir por agente)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"update": {
|
||||
"server_update": {
|
||||
"name": "Actualizar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"connect_direct_access": "Acceso directo desde la máquina de Home Assistant: {url}",
|
||||
"connect_local_lan": "Local/LAN (cuando el acceso desde la red es «Local network»): {url}",
|
||||
"connect_remote_url": "URL de conexión remota: {url}",
|
||||
"connect_urls_label": "URL de conexión:",
|
||||
"connect_urls_pending": "Las URL de conexión aparecen aquí (y en el registro de Home Assistant) una vez que el servidor se ha iniciado.",
|
||||
"connect_webhook_disabled": "El acceso remoto mediante webhook está desactivado (modo solo local).",
|
||||
"oauth_creds_pending": "El Client ID y el Client Secret aparecen aquí una vez que el servidor se ha iniciado.",
|
||||
"oauth_not_serving": "OAuth heredado aún no los está sirviendo — reinicia Home Assistant cuando te lo pida para activarlos.",
|
||||
"oauth_select_legacy_mode": "Pon el modo de autenticación de arriba en OAuth heredado y guarda para generar un Client ID y un Client Secret.",
|
||||
"panel_hint": "Abre el [panel de ajustes de HA-MCP](/ha-mcp) para la gestión de herramientas y los ajustes del servidor.",
|
||||
"tools_module_installed": "Módulo beta/avanzado de herramientas de archivos y YAML (opcional): Instalado",
|
||||
"tools_module_not_installed": "Módulo beta/avanzado de herramientas de archivos y YAML (opcional): No instalado — pulsa «Añadir entrada» en la página de esta integración y elige «HA-MCP File & YAML Tools»",
|
||||
"tools_module_not_loaded": "Módulo beta/avanzado de herramientas de archivos y YAML (opcional): Instalado pero no cargado — activa o vuelve a cargar la entrada «HA-MCP File & YAML Tools» en la página de esta integración",
|
||||
"version_line": "Componente {component_version} - Servidor ha-mcp {server_version} (canal {channel})",
|
||||
"version_not_installed": "aún no instalado",
|
||||
"version_unknown": "desconocido"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Composant personnalisé HA-MCP",
|
||||
"description": "Choisissez ce que vous souhaitez ajouter. **Serveur HA-MCP** exécute le serveur ha-mcp complet à l'intérieur de Home Assistant et l'expose via un webhook Home Assistant – c'est l'installation que la plupart des utilisateurs veulent. C'est un serveur autonome et un remplacement complet de toutes les autres méthodes d'installation (add-on, Docker, uvx/PyPI, stdio) ; si vous l'utilisez, n'en exécutez pas une autre en parallèle. **Outils Fichier & YAML HA-MCP** ajoute les services privilégiés d'édition de fichiers et de YAML, nécessaires uniquement si vous activez les outils fichier/YAML optionnels de ha-mcp. Si votre serveur ha-mcp tourne déjà ailleurs (add-on, Docker, uvx), vous n'avez pas besoin de l'entrée serveur – ajoutez uniquement cette entrée Fichier & YAML, et seulement si vous utilisez ces outils. Elle fonctionne avec tous les types de serveur et peut être ajoutée à tout moment.",
|
||||
"menu_options": {
|
||||
"server": "Serveur HA-MCP (recommandé)",
|
||||
"tools": "Outils Fichier & YAML HA-MCP (optionnel)"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"title": "Outils Fichier & YAML HA-MCP",
|
||||
"description": "Configure les services privilégiés d'édition de fichiers et de YAML. Nécessaire uniquement si vous activez les outils d'édition fichier/YAML optionnels de ha-mcp (drapeaux de fonctionnalité, désactivés par défaut) – cela s'applique à tous les types de serveur, y compris le serveur HA-MCP intégré. Vous pouvez ajouter ou supprimer cette entrée à tout moment."
|
||||
},
|
||||
"server": {
|
||||
"title": "Serveur HA-MCP",
|
||||
"description": "Cela exécute le serveur ha-mcp complet à l'intérieur de Home Assistant et l'expose à distance via un webhook Home Assistant (accessible via Nabu Casa ou tout reverse proxy). C'est un serveur autonome et un remplacement complet des méthodes d'installation add-on, Docker, uvx/PyPI et stdio – ne l'exécutez pas en parallèle d'un autre serveur ha-mcp. Sélectionnez **Soumettre** pour le démarrer ; vous pourrez modifier le port, la liaison et l'authentification ensuite dans les options de l'intégration."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Cette entrée est déjà configurée.",
|
||||
"unsupported_home_assistant": "Le serveur HA-MCP intégré nécessite Home Assistant {required} ou plus récent, mais cette instance utilise {installed}. Vous pouvez toujours utiliser l'entrée HA-MCP File & YAML Tools de ce composant avec un serveur ha-mcp externe fonctionnant en add-on ou conteneur Docker."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"tools_info": {
|
||||
"title": "Outils Fichier & YAML HA-MCP",
|
||||
"description": "Configurez les services privilégiés d'édition de fichiers et de YAML utilisés par les outils fichier/YAML optionnels de ha-mcp. Ces paramètres apparaissent également dans l'interface de configuration du serveur ha-mcp et s'appliquent en direct, sans redémarrage. Un plancher de refus non contournable bloque toujours les chemins sensibles (ex. .storage) et les clés de limite de confiance (homeassistant, http, frontend, lovelace).",
|
||||
"data": {
|
||||
"allowed_dirs": "Répertoires de fichiers supplémentaires",
|
||||
"extra_yaml_keys": "Clés d'écriture YAML supplémentaires"
|
||||
},
|
||||
"data_description": {
|
||||
"allowed_dirs": "Répertoires relatifs au répertoire de configuration, chacun avec accès en lecture et écriture (ex. pyscript). Les traversées et les entrées hors du répertoire de configuration sont ignorées.",
|
||||
"extra_yaml_keys": "Clés de premier niveau que ha_config_set_yaml peut écrire en plus de celles intégrées, pour les intégrations YAML-first de cette installation (ex. alert2). Les clés de limite de confiance sont ignorées."
|
||||
}
|
||||
},
|
||||
"init": {
|
||||
"title": "Serveur HA-MCP",
|
||||
"description": "Configurez le serveur HA-MCP. {panel_hint}Les modifications ici sont appliquées à l'enregistrement.\n\n{versions}\n\n{connect_url}\n\n{oauth_creds}",
|
||||
"data": {
|
||||
"channel": "Canal de publication",
|
||||
"auto_update": "Mises à jour automatiques du serveur",
|
||||
"server_port": "Port d'écoute du serveur MCP",
|
||||
"bind_host": "Accès réseau",
|
||||
"webhook_auth": "Mode d'authentification",
|
||||
"oauth_client_id_override": "OAuth hérité : Client ID personnalisé (optionnel)",
|
||||
"oauth_client_secret_override": "OAuth hérité : Client Secret personnalisé (optionnel)",
|
||||
"oauth_regenerate": "OAuth hérité : régénérer Client ID/Secret maintenant",
|
||||
"pip_spec": "Développeur : surcharge du paquet ha-mcp",
|
||||
"server_url": "URL Home Assistant (avancé)",
|
||||
"external_url": "URL externe (optionnel)",
|
||||
"webhook_id_override": "Secret webhook personnalisé (optionnel)",
|
||||
"secret_path_override": "Chemin d'accès direct personnalisé (optionnel)",
|
||||
"regenerate_secrets": "Régénérer les secrets de connexion maintenant",
|
||||
"enable_webhook": "Accès distant via webhook",
|
||||
"enable_llm_api": "API LLM pour agents de conversation",
|
||||
"llm_api_exposure": "Exposition des outils pour agents de conversation",
|
||||
"enable_startup_notification": "Notification au démarrage",
|
||||
"enable_sidebar_panel": "Panneau de configuration dans la barre latérale"
|
||||
},
|
||||
"data_description": {
|
||||
"channel": "Stable installe la dernière version stable ; Développement installe le dernier build de développement. Lorsque les mises à jour automatiques sont activées, un rechargement ou redémarrage, ainsi qu'une vérification périodique, installent le dernier build du canal sélectionné. Une surcharge de paquet développeur ci-dessous a la priorité et désactive les mises à jour automatiques.",
|
||||
"auto_update": "Lorsqu'activé, la dernière version du canal sélectionné est installée automatiquement – lors d'un rechargement ou redémarrage, et via une vérification périodique. Lorsqu'éteint, le serveur reste sur la version actuellement installée jusqu'à ce que vous le réactiviez. Cela contrôle uniquement le paquet serveur ha-mcp ; les mises à jour du composant personnalisé HA-MCP lui-même passent toujours par HACS.",
|
||||
"server_port": "Le port sur lequel ce serveur écoute. L'add-on ha-mcp utilise 9583, donc la valeur par défaut ici est 9584 pour permettre aux deux de coexister – si vous n'utilisez pas l'add-on, n'importe quel port libre convient.",
|
||||
"bind_host": "Qui peut se connecter directement au port du serveur MCP. La valeur par défaut correspond à l'add-on : accessible sur votre réseau local, avec le chemin secret comme identifiant. Choisissez loopback pour n'autoriser que les connexions depuis la machine Home Assistant elle-même – l'URL webhook et le panneau latéral fonctionnent dans les deux cas.",
|
||||
"webhook_auth": "Comment les clients MCP s'authentifient à l'URL webhook. Avec l'URL secrète, le lien lui-même est l'identifiant. Avec la connexion Home Assistant, les clients comme claude.ai se connectent avec un compte administrateur Home Assistant (OAuth). Avec OAuth hérité, cette intégration émet son propre Client ID et Secret à coller dans les clients qui en ont besoin, comme Google Gemini Spark – un redémarrage de Home Assistant est nécessaire pour l'activer ou le désactiver.",
|
||||
"oauth_client_id_override": "Remplace le Client ID OAuth hérité généré automatiquement. Laissez vide pour conserver l'actuel. Utilisé uniquement lorsque le mode d'authentification est défini sur OAuth hérité.",
|
||||
"oauth_client_secret_override": "Remplace le Client Secret OAuth hérité généré automatiquement. Laissez vide pour conserver l'actuel. Utilisé uniquement lorsque le mode d'authentification est défini sur OAuth hérité.",
|
||||
"oauth_regenerate": "Action unique : génère un nouveau Client ID et Secret pour le mode OAuth hérité. Cela ne prend effet qu'après le redémarrage de Home Assistant demandé par l'invite de réparation – jusqu'au redémarrage, les anciens Client ID et Secret continuent de fonctionner et les nouveaux ne sont pas encore actifs. Efface également les deux champs de surcharge ci-dessus.",
|
||||
"pip_spec": "Laissez vide. Uniquement pour tester un build spécifique de ha-mcp (par exemple un pin de pré-version) ; surcharge le canal de publication et désactive les mises à jour automatiques jusqu'à ce qu'il soit vidé.",
|
||||
"server_url": "L'URL que le serveur intégré utilise pour atteindre votre Home Assistant (généralement cette instance elle-même). Laissez vide pour la dériver du port et des paramètres SSL de cette instance ; ne définissez une valeur que si le serveur a besoin d'une autre route.",
|
||||
"external_url": "Affichée comme URL de connexion principale – utilisez-la lorsque Home Assistant est derrière votre propre domaine ou un reverse proxy. Entrez l'adresse de base complète, y compris le schéma. Elle doit pointer directement vers Home Assistant – l'ouvrir dans un navigateur doit atteindre votre page de connexion HA – et ne doit pas contenir de port tel que :8123 (ou tout autre port), sinon les clients MCP distants ne pourront pas l'atteindre. Laissez vide pour utiliser automatiquement Nabu Casa / l'adresse locale.",
|
||||
"webhook_id_override": "Remplace le secret webhook aléatoire dans l'URL de connexion (/api/webhook/...). L'URL est l'identifiant – utilisez une valeur longue et difficile à deviner. Laissez vide pour conserver l'actuel.",
|
||||
"secret_path_override": "Remplace le chemin aléatoire utilisé pour l'accès direct sur le port du serveur. Même règle : le chemin est l'identifiant. Laissez vide pour conserver l'actuel.",
|
||||
"regenerate_secrets": "Action unique : génère un nouveau secret webhook aléatoire et un chemin d'accès direct, invalidant immédiatement les anciennes URL de connexion. Efface également les deux champs de surcharge ci-dessus.",
|
||||
"enable_webhook": "Désactivez pour le mode local uniquement : le webhook Home Assistant n'est pas enregistré du tout, donc rien – y compris Nabu Casa – ne peut atteindre le serveur via Home Assistant. Le port serveur direct et le panneau latéral continuent de fonctionner.",
|
||||
"enable_llm_api": "Propose l'ensemble complet d'outils aux agents de conversation Home Assistant (OpenAI, Google, Ollama, ...) : lorsqu'activé, les agents peuvent sélectionner « HA-MCP Server » sous Contrôler Home Assistant et utiliser les outils depuis le chat Assist et la voix. L'activation le rend seulement sélectionnable – rien n'est exposé tant que vous ne le choisissez pas sur un agent. Guide d'utilisation : {llm_api_docs_url}",
|
||||
"llm_api_exposure": "Forme de l'ensemble d'outils proposé aux agents de conversation. Recherche d'outils (par défaut) garde le contexte de l'agent compact : une API compacte avec des outils épinglés plus des méta-outils de recherche/exécution. Catalogue complet liste directement chaque outil exposé – mieux pour les modèles à grand contexte. Les deux enregistre les deux côte à côte afin que chaque agent choisisse le sien sous Contrôler Home Assistant. L'exposition par outil est gérée dans le panneau de configuration HA-MCP ; détails : {llm_api_docs_url}",
|
||||
"enable_startup_notification": "Affiche une notification à chaque démarrage du serveur, pointant vers les surfaces de configuration réservées aux administrateurs. Désactivez pour démarrer silencieusement – les URL de connexion apparaissent toujours dans le journal Home Assistant.",
|
||||
"enable_sidebar_panel": "Affiche le panneau de configuration HA-MCP dans la barre latérale (administrateurs uniquement). Désactivez pour supprimer l'entrée de la barre latérale – les options du serveur restent disponibles sur cet écran."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"server_start_failed": {
|
||||
"title": "Échec du démarrage du serveur HA-MCP intégré",
|
||||
"description": "Le serveur HA-MCP intégré n'a pas pu être démarré à l'intérieur de Home Assistant :\n\n{detail}\n\nVérifiez les journaux Home Assistant, puis rechargez l'intégration (ou corrigez le problème sous-jacent et rechargez) pour réessayer."
|
||||
},
|
||||
"server_package_install_failed": {
|
||||
"title": "Échec de l'installation du paquet serveur HA-MCP intégré",
|
||||
"description": "L'installation du paquet ha-mcp pour le serveur intégré a échoué :\n\n{detail}\n\nRésolvez le problème de compatibilité ou d'installation décrit ci-dessus, puis rechargez l'intégration pour réessayer."
|
||||
},
|
||||
"component_outdated": {
|
||||
"title": "Mettez à jour le composant personnalisé HA-MCP via HACS",
|
||||
"description": "Le serveur ha-mcp installé nécessite le composant personnalisé HA-MCP {required} ou plus récent, mais vous avez {installed}. Mettez à jour le composant via HACS (ouvrez l'entrée Composant personnalisé HA-MCP et utilisez « Update information » si aucune mise à jour n'est encore affichée) et redémarrez Home Assistant. Le serveur continue de fonctionner entre-temps, mais certaines fonctionnalités plus récentes peuvent ne pas fonctionner tant que le composant n'est pas mis à jour."
|
||||
},
|
||||
"server_update_held": {
|
||||
"title": "Mise à jour du serveur HA-MCP en attente d'une mise à jour du composant",
|
||||
"description": "Le serveur ha-mcp {latest} est disponible, mais cette version a également mis à jour le composant personnalisé HA-MCP (vers {shipped} ; vous utilisez {running}). Pour éviter de démarrer une version du serveur avec laquelle le composant en cours n'a jamais été testé, la mise à jour automatique du serveur est en attente jusqu'à la mise à jour du composant.\n\nMettez à jour le composant via HACS (ouvrez l'entrée Composant personnalisé HA-MCP et utilisez « Update information » si aucune mise à jour n'est encore affichée), puis redémarrez Home Assistant – la mise à jour du serveur s'installera automatiquement ensuite. Pour installer quand même la mise à jour du serveur, appuyez sur Installer sur l'entité de mise à jour du serveur HA-MCP."
|
||||
},
|
||||
"legacy_hacs_source": {
|
||||
"title": "Composant installé depuis le dépôt hérité",
|
||||
"description": "HACS suit le dépôt principal du serveur ha-mcp pour ce composant, donc HACS affiche ici les numéros de version du serveur (7.x) et les notes de version du serveur au lieu de celles du composant (1.x). Les mises à jour continuent de fonctionner, mais restent mal étiquetées de cette façon. Pour corriger : supprimez ce dépôt de HACS (vos paramètres d'intégration et entrées de configuration sont conservés), ajoutez homeassistant-ai/ha-mcp-integration comme dépôt personnalisé, réinstallez le composant depuis celui-ci, et redémarrez Home Assistant."
|
||||
},
|
||||
"legacy_oauth_restart": {
|
||||
"title": "Redémarrez Home Assistant pour appliquer le changement OAuth hérité",
|
||||
"description": "Le mode d'authentification OAuth hérité enregistre ses propres points de terminaison web /authorize et /token, que Home Assistant ne peut lier ou libérer que lors d'un redémarrage complet – que vous veniez d'activer ce mode, de le désactiver, ou de modifier son Client ID/Secret. Redémarrez Home Assistant (Paramètres - Système - Redémarrer) pour appliquer le changement ; jusqu'alors le comportement précédent reste en vigueur."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"server_channel": {
|
||||
"options": {
|
||||
"stable": "Stable (recommandé)",
|
||||
"dev": "Développement (dernier build)"
|
||||
}
|
||||
},
|
||||
"server_webhook_auth": {
|
||||
"options": {
|
||||
"none": "URL webhook secrète (par défaut)",
|
||||
"ha_auth": "Connexion avec Home Assistant (OAuth)",
|
||||
"legacy": "OAuth hérité (Client ID/Secret, pour Google Gemini Spark)"
|
||||
}
|
||||
},
|
||||
"llm_api_exposure": {
|
||||
"options": {
|
||||
"tool_search": "Recherche d'outils (compact, par défaut)",
|
||||
"full": "Catalogue complet",
|
||||
"both": "Les deux (choix par agent)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"update": {
|
||||
"server_update": {
|
||||
"name": "Mise à jour"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"connect_direct_access": "Accès direct depuis la machine Home Assistant : {url}",
|
||||
"connect_local_lan": "Local/LAN (lorsque l'accès réseau est « Local network ») : {url}",
|
||||
"connect_remote_url": "URL de connexion distante : {url}",
|
||||
"connect_urls_label": "URL de connexion :",
|
||||
"connect_urls_pending": "Les URL de connexion apparaissent ici (et dans le journal Home Assistant) une fois le serveur démarré.",
|
||||
"connect_webhook_disabled": "L'accès distant via webhook est désactivé (mode local uniquement).",
|
||||
"oauth_creds_pending": "Le Client ID et le Client Secret apparaissent ici une fois le serveur démarré.",
|
||||
"oauth_not_serving": "OAuth hérité ne les sert pas encore — redémarrez Home Assistant lorsqu'il vous le demande pour les activer.",
|
||||
"oauth_select_legacy_mode": "Réglez le mode d'authentification ci-dessus sur OAuth hérité et enregistrez pour générer un Client ID et un Client Secret.",
|
||||
"panel_hint": "Ouvrez le [panneau de réglages HA-MCP](/ha-mcp) pour la gestion des outils et les réglages du serveur.",
|
||||
"tools_module_installed": "Module bêta/avancé d'outils fichiers & YAML (facultatif) : Installé",
|
||||
"tools_module_not_installed": "Module bêta/avancé d'outils fichiers & YAML (facultatif) : Non installé — appuyez sur « Ajouter une entrée » sur la page de cette intégration et choisissez « HA-MCP File & YAML Tools »",
|
||||
"tools_module_not_loaded": "Module bêta/avancé d'outils fichiers & YAML (facultatif) : Installé mais non chargé — activez ou rechargez l'entrée « HA-MCP File & YAML Tools » sur la page de cette intégration",
|
||||
"version_line": "Composant {component_version} - Serveur ha-mcp {server_version} (canal {channel})",
|
||||
"version_not_installed": "pas encore installé",
|
||||
"version_unknown": "inconnu"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Componente personalizzato HA-MCP",
|
||||
"description": "Scegli cosa aggiungere. **Server HA-MCP** esegue il server ha-mcp completo dentro Home Assistant e lo espone tramite un webhook di Home Assistant: è l'installazione che va bene per la maggior parte delle persone. È un server autonomo e sostituisce completamente ogni altro metodo di installazione (componente aggiuntivo, Docker, uvx/PyPI, stdio); se usi questo, non usarne anche un altro. **Strumenti file e YAML di HA-MCP** aggiunge i servizi privilegiati di modifica di file e YAML, necessari solo se attivi gli strumenti file/YAML opzionali di ha-mcp. Se il tuo server ha-mcp gira già altrove (componente aggiuntivo, Docker, uvx), non ti serve la voce del server: aggiungi solo questa voce file e YAML, e solo se usi quegli strumenti. Funziona con qualsiasi tipo di server e si può aggiungere in seguito in qualsiasi momento.",
|
||||
"menu_options": {
|
||||
"server": "Server HA-MCP (consigliato)",
|
||||
"tools": "Strumenti file e YAML di HA-MCP (facoltativo)"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"title": "Strumenti file e YAML di HA-MCP",
|
||||
"description": "Configura i servizi privilegiati per i file e per la configurazione YAML. Servono solo se attivi gli strumenti opzionali di modifica file/YAML di ha-mcp (indicatori di funzione, disattivati per impostazione predefinita): vale per qualsiasi tipo di server, incluso il server HA-MCP eseguito nel processo. Puoi aggiungere o rimuovere questa voce in qualsiasi momento."
|
||||
},
|
||||
"server": {
|
||||
"title": "Server HA-MCP",
|
||||
"description": "Esegue il server ha-mcp completo dentro Home Assistant e lo espone da remoto tramite un webhook di Home Assistant (raggiungibile con Nabu Casa o con qualsiasi proxy inverso). È un server autonomo e sostituisce completamente i metodi di installazione tramite componente aggiuntivo, Docker, uvx/PyPI e stdio: non eseguirlo insieme a un altro server ha-mcp. Premi **Invia** per avviarlo; porta, associazione di rete e autenticazione si possono cambiare dopo, nelle opzioni dell'integrazione."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Questa voce è già configurata.",
|
||||
"unsupported_home_assistant": "Il server HA-MCP eseguito nel processo richiede Home Assistant {required} o successivo, ma questa istanza esegue {installed}. Puoi comunque usare la voce HA-MCP File & YAML Tools di questo componente con un server ha-mcp esterno eseguito come componente aggiuntivo o container Docker."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"tools_info": {
|
||||
"title": "Strumenti file e YAML di HA-MCP",
|
||||
"description": "Configura i servizi privilegiati di modifica di file e YAML usati dagli strumenti file/YAML opzionali di ha-mcp. Queste impostazioni compaiono anche nell'interfaccia delle impostazioni del server ha-mcp e si applicano subito, senza riavvio. Una soglia di divieto non aggirabile continua a bloccare i percorsi sensibili (per esempio .storage) e le chiavi del confine di fiducia (homeassistant, http, frontend, lovelace).",
|
||||
"data": {
|
||||
"allowed_dirs": "Directory di file aggiuntive",
|
||||
"extra_yaml_keys": "Chiavi YAML scrivibili aggiuntive"
|
||||
},
|
||||
"data_description": {
|
||||
"allowed_dirs": "Directory relative alla directory di configurazione, ciascuna con permesso di lettura e scrittura (per esempio pyscript). I percorsi che risalgono l'albero e le voci fuori dalla configurazione vengono scartati.",
|
||||
"extra_yaml_keys": "Chiavi di primo livello che ha_config_set_yaml può scrivere oltre a quelle integrate, per le integrazioni basate su YAML di questa installazione (per esempio alert2). Le chiavi del confine di fiducia vengono scartate."
|
||||
}
|
||||
},
|
||||
"init": {
|
||||
"title": "Server HA-MCP",
|
||||
"description": "Configura il server HA-MCP. {panel_hint}Le modifiche qui vengono applicate al salvataggio.\n\n{versions}\n\n{connect_url}\n\n{oauth_creds}",
|
||||
"data": {
|
||||
"channel": "Canale di rilascio",
|
||||
"auto_update": "Aggiornamenti automatici del server",
|
||||
"server_port": "Porta di ascolto del server MCP",
|
||||
"bind_host": "Accesso dalla rete",
|
||||
"webhook_auth": "Modalità di autenticazione",
|
||||
"oauth_client_id_override": "OAuth legacy: Client ID personalizzato (facoltativo)",
|
||||
"oauth_client_secret_override": "OAuth legacy: Client Secret personalizzato (facoltativo)",
|
||||
"oauth_regenerate": "OAuth legacy: rigenera subito Client ID e Client Secret",
|
||||
"pip_spec": "Sviluppo: pacchetto ha-mcp alternativo",
|
||||
"server_url": "URL di Home Assistant (avanzato)",
|
||||
"external_url": "URL esterno (facoltativo)",
|
||||
"webhook_id_override": "Segreto del webhook personalizzato (facoltativo)",
|
||||
"secret_path_override": "Percorso di accesso diretto personalizzato (facoltativo)",
|
||||
"regenerate_secrets": "Rigenera subito i segreti di connessione",
|
||||
"enable_webhook": "Accesso remoto tramite webhook",
|
||||
"enable_llm_api": "API LLM per gli agenti conversazionali",
|
||||
"llm_api_exposure": "Esposizione degli strumenti agli agenti conversazionali",
|
||||
"enable_startup_notification": "Notifica all'avvio",
|
||||
"enable_sidebar_panel": "Pannello impostazioni nella barra laterale"
|
||||
},
|
||||
"data_description": {
|
||||
"channel": "Stabile installa l'ultima versione stabile; Sviluppo installa la build di sviluppo più recente. Con gli aggiornamenti automatici attivi, una ricarica o un riavvio, più un controllo periodico, installano la build più recente del canale scelto. Il pacchetto alternativo di sviluppo qui sotto ha la precedenza e disattiva gli aggiornamenti automatici.",
|
||||
"auto_update": "Quando è attivo, la versione più recente del canale scelto viene installata automaticamente: a una ricarica o a un riavvio e tramite un controllo periodico. Quando è disattivo, il server resta alla versione installata finché non lo riattivi. Questo riguarda solo il pacchetto del server ha-mcp; gli aggiornamenti del componente personalizzato HA-MCP continuano ad arrivare tramite HACS.",
|
||||
"server_port": "La porta su cui questo server ascolta. Il componente aggiuntivo ha-mcp usa la 9583, quindi qui il valore predefinito è 9584 per farli convivere; se non usi il componente aggiuntivo, va bene qualsiasi porta libera.",
|
||||
"bind_host": "Chi può collegarsi direttamente alla porta del server MCP. Il valore predefinito coincide con quello del componente aggiuntivo: raggiungibile sulla rete locale, con il percorso segreto come credenziale. Scegli loopback per consentire solo le connessioni dalla macchina di Home Assistant stessa: l'URL del webhook e il pannello nella barra laterale funzionano in entrambi i casi.",
|
||||
"webhook_auth": "Come i client MCP si identificano all'URL del webhook. Con l'URL segreto, il collegamento stesso è la credenziale. Con l'accesso tramite Home Assistant, client come claude.ai entrano con un account amministratore di Home Assistant (OAuth). Con OAuth legacy questa integrazione emette un proprio Client ID e Client Secret da incollare nei client che li richiedono, come Google Gemini Spark: attivarlo o disattivarlo richiede il riavvio di Home Assistant.",
|
||||
"oauth_client_id_override": "Sostituisce il Client ID OAuth legacy generato automaticamente. Lascialo vuoto per mantenere quello attuale. Viene usato solo mentre la modalità di autenticazione è impostata su OAuth legacy.",
|
||||
"oauth_client_secret_override": "Sostituisce il Client Secret OAuth legacy generato automaticamente. Lascialo vuoto per mantenere quello attuale. Viene usato solo mentre la modalità di autenticazione è impostata su OAuth legacy.",
|
||||
"oauth_regenerate": "Azione una tantum: genera un Client ID e un Client Secret nuovi per la modalità OAuth legacy. Ha effetto solo dopo il riavvio di Home Assistant che la richiesta di riparazione chiede; fino ad allora continuano a funzionare i precedenti e non i nuovi. Svuota anche i due campi di sostituzione qui sopra.",
|
||||
"pip_spec": "Lascialo vuoto. Serve solo per provare una build specifica di ha-mcp (per esempio una versione preliminare fissata); ha la precedenza sul canale di rilascio e disattiva gli aggiornamenti automatici finché non lo svuoti.",
|
||||
"server_url": "L'URL che il server nel processo usa per raggiungere il tuo Home Assistant (di solito questa stessa istanza). Lascialo vuoto per ricavarlo dalla porta e dalle impostazioni SSL di questa istanza; indica un valore solo se al server serve un percorso diverso.",
|
||||
"external_url": "Viene mostrato come URL di connessione principale: usalo quando Home Assistant sta dietro un tuo dominio o un proxy inverso. Inserisci l'indirizzo di base completo, schema incluso. Deve puntare direttamente a Home Assistant — aprendolo in un browser dovresti arrivare alla pagina di accesso di HA — e non deve contenere una porta come :8123 (né altre), altrimenti i client MCP remoti non riusciranno a raggiungerlo. Lascialo vuoto per usare automaticamente Nabu Casa o l'indirizzo locale.",
|
||||
"webhook_id_override": "Sostituisce il segreto casuale del webhook nell'URL di connessione (/api/webhook/...). L'URL è la credenziale: usa un valore lungo e difficile da indovinare. Lascialo vuoto per mantenere quello attuale.",
|
||||
"secret_path_override": "Sostituisce il percorso casuale usato per l'accesso diretto sulla porta del server. Stessa regola: il percorso è la credenziale. Lascialo vuoto per mantenere quello attuale.",
|
||||
"regenerate_secrets": "Azione una tantum: genera un nuovo segreto casuale per il webhook e un nuovo percorso di accesso diretto, invalidando subito i vecchi URL di connessione. Svuota anche i due campi di sostituzione qui sopra.",
|
||||
"enable_webhook": "Disattivalo per la modalità solo locale: il webhook di Home Assistant non viene registrato affatto, quindi nulla — Nabu Casa inclusa — può raggiungere il server attraverso Home Assistant. La porta diretta del server e il pannello nella barra laterale continuano a funzionare.",
|
||||
"enable_llm_api": "Offre l'intero set di strumenti agli agenti conversazionali di Home Assistant (OpenAI, Google, Ollama, ...): mentre è attivo, gli agenti possono selezionare «HA-MCP Server» sotto Controlla Home Assistant e usare gli strumenti dalla chat e dalla voce di Assist. Attivarlo lo rende solo selezionabile: non viene esposto nulla finché non lo scegli su un agente. Guida all'uso: {llm_api_docs_url}",
|
||||
"llm_api_exposure": "Forma del set di strumenti offerto agli agenti conversazionali. La ricerca degli strumenti (predefinita) mantiene piccolo il contesto dell'agente: un'API compatta con strumenti fissati più meta-strumenti di ricerca ed esecuzione. Il catalogo completo elenca direttamente ogni strumento esposto, meglio per modelli con contesto ampio. Entrambi registra le due opzioni affiancate, così ogni agente sceglie la propria sotto Controlla Home Assistant. L'esposizione per singolo strumento si gestisce nel pannello impostazioni di HA-MCP; dettagli: {llm_api_docs_url}",
|
||||
"enable_startup_notification": "Mostra una notifica a ogni avvio del server, che rimanda alle superfici di impostazioni riservate agli amministratori. Disattivalo per un avvio silenzioso: gli URL di connessione restano comunque nel registro di Home Assistant.",
|
||||
"enable_sidebar_panel": "Mostra il pannello impostazioni di HA-MCP nella barra laterale (solo per gli amministratori). Disattivalo per togliere la voce dalla barra laterale: le opzioni del server restano disponibili in questa schermata."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"server_start_failed": {
|
||||
"title": "Il server HA-MCP nel processo non è riuscito ad avviarsi",
|
||||
"description": "Non è stato possibile avviare il server HA-MCP nel processo dentro Home Assistant:\n\n{detail}\n\nControlla i registri di Home Assistant, poi ricarica l'integrazione (o risolvi il problema di fondo e ricaricala) per riprovare."
|
||||
},
|
||||
"server_package_install_failed": {
|
||||
"title": "Non è stato possibile installare il pacchetto del server HA-MCP nel processo",
|
||||
"description": "L'installazione del pacchetto ha-mcp per il server nel processo non è riuscita:\n\n{detail}\n\nRisolvi il problema di compatibilità o di installazione descritto sopra, poi ricarica l'integrazione per riprovare."
|
||||
},
|
||||
"component_outdated": {
|
||||
"title": "Aggiorna il componente personalizzato HA-MCP tramite HACS",
|
||||
"description": "Il server ha-mcp installato richiede il componente personalizzato HA-MCP {required} o successivo, ma hai {installed}. Aggiorna il componente tramite HACS (apri la voce del componente personalizzato HA-MCP e usa «Aggiorna informazioni» se non compare ancora alcun aggiornamento) e riavvia Home Assistant. Nel frattempo il server continua a funzionare, ma alcune funzioni più recenti potrebbero non funzionare finché il componente non viene aggiornato."
|
||||
},
|
||||
"server_update_held": {
|
||||
"title": "Aggiornamento del server HA-MCP in attesa di un aggiornamento del componente",
|
||||
"description": "È disponibile il server ha-mcp {latest}, ma quella versione ha aggiornato anche il componente personalizzato HA-MCP (alla {shipped}; tu esegui la {running}). Per evitare di avviare una versione del server con cui il componente in uso non è mai stato provato, l'aggiornamento automatico del server resta sospeso finché il componente non viene aggiornato.\n\nAggiorna il componente tramite HACS (apri la voce del componente personalizzato HA-MCP e usa «Aggiorna informazioni» se non compare ancora alcun aggiornamento), poi riavvia Home Assistant: l'aggiornamento del server si installa automaticamente subito dopo. Per installare comunque l'aggiornamento del server, premi Installa sull'entità di aggiornamento del server HA-MCP."
|
||||
},
|
||||
"legacy_hacs_source": {
|
||||
"title": "Componente installato dal repository obsoleto",
|
||||
"description": "Per questo componente HACS sta seguendo il repository principale del server ha-mcp, quindi qui mostra i numeri di versione del server (7.x) e le sue note di rilascio invece di quelli del componente (1.x). Gli aggiornamenti continuano a funzionare, ma restano etichettati in modo sbagliato. Per rimediare: rimuovi questo repository da HACS (le impostazioni dell'integrazione e le voci di configurazione vengono mantenute), aggiungi homeassistant-ai/ha-mcp-integration come repository personalizzato, reinstalla il componente da lì e riavvia Home Assistant."
|
||||
},
|
||||
"legacy_oauth_restart": {
|
||||
"title": "Riavvia Home Assistant per applicare la modifica a OAuth legacy",
|
||||
"description": "La modalità di autenticazione OAuth legacy registra propri endpoint web /authorize e /token, che Home Assistant può associare o rilasciare solo con un riavvio completo, sia che tu abbia appena attivato questa modalità, sia che l'abbia disattivata o ne abbia cambiato Client ID o Client Secret. Riavvia Home Assistant (Impostazioni - Sistema - Riavvia) per applicare la modifica; fino ad allora resta valido il comportamento precedente."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"server_channel": {
|
||||
"options": {
|
||||
"stable": "Stabile (consigliato)",
|
||||
"dev": "Sviluppo (ultima build)"
|
||||
}
|
||||
},
|
||||
"server_webhook_auth": {
|
||||
"options": {
|
||||
"none": "URL webhook segreto (predefinito)",
|
||||
"ha_auth": "Accedi con Home Assistant (OAuth)",
|
||||
"legacy": "OAuth legacy (Client ID e Client Secret, per Google Gemini Spark)"
|
||||
}
|
||||
},
|
||||
"llm_api_exposure": {
|
||||
"options": {
|
||||
"tool_search": "Ricerca degli strumenti (compatta, predefinita)",
|
||||
"full": "Catalogo completo",
|
||||
"both": "Entrambi (scelta per agente)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"update": {
|
||||
"server_update": {
|
||||
"name": "Aggiornamento"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"connect_direct_access": "Accesso diretto dalla macchina Home Assistant: {url}",
|
||||
"connect_local_lan": "Locale/LAN (quando l'accesso dalla rete è «Local network»): {url}",
|
||||
"connect_remote_url": "URL di connessione remota: {url}",
|
||||
"connect_urls_label": "URL di connessione:",
|
||||
"connect_urls_pending": "Gli URL di connessione compaiono qui (e nel registro di Home Assistant) una volta avviato il server.",
|
||||
"connect_webhook_disabled": "L'accesso remoto tramite webhook è disattivato (modalità solo locale).",
|
||||
"oauth_creds_pending": "Il Client ID e il Client Secret compaiono qui una volta avviato il server.",
|
||||
"oauth_not_serving": "OAuth legacy non li sta ancora servendo — riavvia Home Assistant quando te lo chiede per attivarli.",
|
||||
"oauth_select_legacy_mode": "Imposta la modalità di autenticazione qui sopra su OAuth legacy e salva per generare un Client ID e un Client Secret.",
|
||||
"panel_hint": "Apri il [pannello impostazioni HA-MCP](/ha-mcp) per la gestione degli strumenti e le impostazioni del server.",
|
||||
"tools_module_installed": "Modulo beta/avanzato di strumenti per file e YAML (facoltativo): Installato",
|
||||
"tools_module_not_installed": "Modulo beta/avanzato di strumenti per file e YAML (facoltativo): Non installato — premi «Aggiungi voce» nella pagina di questa integrazione e scegli «HA-MCP File & YAML Tools»",
|
||||
"tools_module_not_loaded": "Modulo beta/avanzato di strumenti per file e YAML (facoltativo): Installato ma non caricato — attiva o ricarica la voce «HA-MCP File & YAML Tools» nella pagina di questa integrazione",
|
||||
"version_line": "Componente {component_version} - Server ha-mcp {server_version} (canale {channel})",
|
||||
"version_not_installed": "non ancora installato",
|
||||
"version_unknown": "sconosciuto"
|
||||
}
|
||||
}
|
||||
@@ -20,14 +20,22 @@
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Эта запись уже настроена.",
|
||||
"unsupported_home_assistant": "Для внутрипроцессного сервера HA-MCP требуется Home Assistant версии {required} или новее, но в этом экземпляре установлена версия {installed}. Вы по-прежнему можете использовать запись «Файловые инструменты и инструменты YAML HA-MCP» этого компонента с внешним сервером ha-mcp, работающим как дополнение или контейнер Docker."
|
||||
"unsupported_home_assistant": "Для внутрипроцессного сервера HA-MCP требуется Home Assistant версии {required} или новее, но в этом экземпляре установлена версия {installed}. Вы по-прежнему можете использовать запись «HA-MCP File & YAML Tools» этого компонента с внешним сервером ha-mcp, работающим как дополнение или контейнер Docker."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"tools_info": {
|
||||
"title": "Файловые инструменты и инструменты YAML HA-MCP",
|
||||
"description": "Эта запись предоставляет привилегированные службы для работы с файлами и редактирования YAML, которые используются отключёнными по умолчанию файловыми инструментами и инструментами YAML в ha-mcp. Пока здесь нечего настраивать. Каталоги, доступные файловым инструментам, задаются в собственных настройках сервера ha-mcp (в его интерфейсе настроек, в разделе разрешённых путей), а не здесь. В будущей версии на этом экране могут появиться параметры для отдельных записей."
|
||||
"description": "Настройка привилегированных служб для работы с файлами и редактирования YAML, которые используются отключёнными по умолчанию файловыми инструментами и инструментами YAML в ha-mcp. Эти параметры также отображаются в собственном интерфейсе настроек сервера ha-mcp и применяются сразу, без перезапуска. Неотключаемый базовый запрет по-прежнему блокирует чувствительные пути (например, .storage) и ключи границы доверия (homeassistant, http, frontend, lovelace).",
|
||||
"data": {
|
||||
"allowed_dirs": "Дополнительные файловые каталоги",
|
||||
"extra_yaml_keys": "Дополнительные YAML-ключи для записи"
|
||||
},
|
||||
"data_description": {
|
||||
"allowed_dirs": "Каталоги относительно каталога конфигурации, каждому предоставляется доступ на чтение и запись (например, pyscript). Записи с обходом пути и вне каталога конфигурации отбрасываются.",
|
||||
"extra_yaml_keys": "Верхнеуровневые ключи, которые ha_config_set_yaml может записывать вдобавок к встроенным, — для YAML-ориентированных интеграций на этой установке (например, alert2). Ключи границы доверия отбрасываются."
|
||||
}
|
||||
},
|
||||
"init": {
|
||||
"title": "Сервер HA-MCP",
|
||||
@@ -57,7 +65,7 @@
|
||||
"channel": "Стабильный канал устанавливает последнюю стабильную версию, а канал разработки — самую новую сборку для разработки. Если автоматические обновления включены, последняя сборка выбранного канала устанавливается при перезагрузке интеграции или перезапуске, а также во время периодической проверки. Указанное ниже переопределение пакета для разработчиков имеет приоритет и отключает автоматические обновления.",
|
||||
"auto_update": "Если параметр включён, последняя версия выбранного канала устанавливается автоматически при перезагрузке интеграции или перезапуске, а также во время периодической проверки. Если параметр выключен, сервер остаётся на текущей установленной версии, пока вы снова не включите его. Это относится только к пакету сервера ha-mcp; обновления самого пользовательского компонента HA-MCP по-прежнему устанавливаются через HACS.",
|
||||
"server_port": "Порт, который прослушивает этот сервер. Дополнение ha-mcp использует порт 9583, поэтому по умолчанию выбран порт 9584, чтобы оба сервера могли работать одновременно. Если дополнение не используется, подойдёт любой свободный порт.",
|
||||
"bind_host": "Определяет, кто может подключаться непосредственно к порту MCP-сервера. Значение по умолчанию соответствует дополнению: сервер доступен в локальной сети, а секретный путь используется как учётные данные. Выберите кольцевой интерфейс, чтобы разрешить подключения только с самого компьютера Home Assistant. URL-адрес вебхука и панель в боковом меню будут работать в любом случае.",
|
||||
"bind_host": "Определяет, кто может подключаться непосредственно к порту MCP-сервера. Значение по умолчанию соответствует дополнению: сервер доступен в локальной сети, а секретный путь используется как учётные данные. Выберите loopback, чтобы разрешить подключения только с самого компьютера Home Assistant. URL-адрес вебхука и панель в боковом меню будут работать в любом случае.",
|
||||
"webhook_auth": "Определяет, как MCP-клиенты подтверждают свою подлинность при обращении к URL-адресу вебхука. При использовании секретного URL-адреса учётными данными служит сама ссылка. При использовании входа через Home Assistant такие клиенты, как claude.ai, входят с учётной записью администратора Home Assistant (OAuth). При использовании устаревшего OAuth интеграция выдаёт собственные Client ID и Secret, которые нужно вставить в клиенты, где они требуются, например Google Gemini Spark. Для включения или выключения этого режима необходимо перезапустить Home Assistant.",
|
||||
"oauth_client_id_override": "Заменяет автоматически созданный Client ID устаревшего OAuth. Оставьте поле пустым, чтобы сохранить текущее значение. Используется, только когда выбран режим аутентификации «Устаревший OAuth».",
|
||||
"oauth_client_secret_override": "Заменяет автоматически созданный Client Secret устаревшего OAuth. Оставьте поле пустым, чтобы сохранить текущее значение. Используется, только когда выбран режим аутентификации «Устаревший OAuth».",
|
||||
@@ -69,7 +77,7 @@
|
||||
"secret_path_override": "Заменяет случайный путь для прямого доступа через порт сервера. Действует то же правило: путь служит учётными данными. Оставьте поле пустым, чтобы сохранить текущее значение.",
|
||||
"regenerate_secrets": "Одноразовое действие: создаёт новый случайный секрет вебхука и путь прямого доступа, немедленно делая прежние URL-адреса подключения недействительными. Также очищает два поля переопределения выше.",
|
||||
"enable_webhook": "Выключите для работы только в локальном режиме: вебхук Home Assistant вообще не будет зарегистрирован, поэтому ничто, включая Nabu Casa, не сможет обратиться к серверу через Home Assistant. Прямой порт сервера и панель в боковом меню продолжат работать.",
|
||||
"enable_llm_api": "Предоставляет полный набор инструментов диалоговым агентам Home Assistant (OpenAI, Google, Ollama и другим). Пока параметр включён, агенты могут выбрать «Сервер HA-MCP» в разделе управления Home Assistant и использовать инструменты из чата и голосового интерфейса Assist. Включение лишь делает его доступным для выбора: ничего не предоставляется, пока вы не выберете его в настройках агента. Руководство по использованию: {llm_api_docs_url}",
|
||||
"enable_llm_api": "Предоставляет полный набор инструментов диалоговым агентам Home Assistant (OpenAI, Google, Ollama и другим). Пока параметр включён, агенты могут выбрать «HA-MCP Server» в разделе управления Home Assistant и использовать инструменты из чата и голосового интерфейса 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 в боковом меню (только для администраторов). Выключите, чтобы убрать пункт из бокового меню. Параметры сервера останутся доступны на этом экране."
|
||||
@@ -131,5 +139,23 @@
|
||||
"name": "Обновление"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"connect_direct_access": "Прямой доступ с компьютера Home Assistant: {url}",
|
||||
"connect_local_lan": "Локально/LAN (когда сетевой доступ — «Local network»): {url}",
|
||||
"connect_remote_url": "URL-адрес удалённого подключения: {url}",
|
||||
"connect_urls_label": "URL-адреса подключения:",
|
||||
"connect_urls_pending": "URL-адреса подключения появятся здесь (и в журнале Home Assistant) после запуска сервера.",
|
||||
"connect_webhook_disabled": "Удалённый доступ через вебхук отключён (только локальный режим).",
|
||||
"oauth_creds_pending": "Client ID и Client Secret появятся здесь после запуска сервера.",
|
||||
"oauth_not_serving": "Устаревший OAuth пока их не обслуживает — перезапустите Home Assistant, когда он об этом попросит, чтобы активировать их.",
|
||||
"oauth_select_legacy_mode": "Установите режим аутентификации выше на устаревший OAuth и сохраните, чтобы создать Client ID и Client Secret.",
|
||||
"panel_hint": "Откройте [панель настроек HA-MCP](/ha-mcp) для управления инструментами и настройками сервера.",
|
||||
"tools_module_installed": "Бета-модуль расширенных инструментов для файлов и YAML (необязательно): Установлен",
|
||||
"tools_module_not_installed": "Бета-модуль расширенных инструментов для файлов и YAML (необязательно): Не установлен — нажмите «Добавить запись» на странице этой интеграции и выберите «HA-MCP File & YAML Tools»",
|
||||
"tools_module_not_loaded": "Бета-модуль расширенных инструментов для файлов и YAML (необязательно): Установлен, но не загружен — включите или перезагрузите запись «HA-MCP File & YAML Tools» на странице этой интеграции",
|
||||
"version_line": "Компонент {component_version} - Сервер ha-mcp {server_version} (канал {channel})",
|
||||
"version_not_installed": "ещё не установлен",
|
||||
"version_unknown": "неизвестен"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"config": {
|
||||
"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 条目,并且仅在你使用这些工具时才添加。它适用于所有服务器类型,随时可以后续添加。",
|
||||
"menu_options": {
|
||||
"server": "HA-MCP 服务器(推荐)",
|
||||
"tools": "HA-MCP 文件与 YAML 工具(可选)"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"title": "HA-MCP 文件与 YAML 工具",
|
||||
"description": "设置特权文件和 YAML 配置服务。仅在你启用 ha-mcp 的可选文件/YAML 编辑工具(功能开关,默认关闭)时才需要——这适用于所有服务器类型,包括进程内的 HA-MCP 服务器。你可以随时添加或移除此条目。"
|
||||
},
|
||||
"server": {
|
||||
"title": "HA-MCP 服务器",
|
||||
"description": "这会在 Home Assistant 内部运行完整的 ha-mcp 服务器,并通过 Home Assistant Webhook 远程提供服务(可经由 Nabu Casa 或任意反向代理访问)。它是一个独立的服务器,可完全取代插件、Docker、uvx/PyPI 和 stdio 安装方式——不要与其他 ha-mcp 服务器同时运行。选择**提交**以启动它;启动后可在集成选项中更改端口、绑定方式和身份验证。"
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "此条目已配置。",
|
||||
"unsupported_home_assistant": "进程内 HA-MCP 服务器需要 Home Assistant {required} 或更高版本,但此实例运行的是 {installed}。你仍可将本组件的 HA-MCP File & YAML Tools 条目与以插件或 Docker 容器方式运行的外部 ha-mcp 服务器搭配使用。"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"tools_info": {
|
||||
"title": "HA-MCP 文件与 YAML 工具",
|
||||
"description": "配置 ha-mcp 可选文件/YAML 工具所使用的特权文件和 YAML 编辑服务。这些设置也会出现在 ha-mcp 服务器自身的设置界面中,并实时生效,无需重启。不可覆盖的拒绝底线仍会阻止敏感路径(例如 .storage)和信任边界键(homeassistant、http、frontend、lovelace)。",
|
||||
"data": {
|
||||
"allowed_dirs": "额外的文件目录",
|
||||
"extra_yaml_keys": "额外的 YAML 可写键"
|
||||
},
|
||||
"data_description": {
|
||||
"allowed_dirs": "相对于配置目录的目录,每个目录都授予读写权限(例如 pyscript)。路径穿越以及配置目录之外的条目会被丢弃。",
|
||||
"extra_yaml_keys": "除内置键外,ha_config_set_yaml 还可写入的顶层键,适用于本安装中以 YAML 为主的集成(例如 alert2)。信任边界键会被丢弃。"
|
||||
}
|
||||
},
|
||||
"init": {
|
||||
"title": "HA-MCP 服务器",
|
||||
"description": "配置 HA-MCP 服务器。{panel_hint}此处的更改在保存时应用。\n\n{versions}\n\n{connect_url}\n\n{oauth_creds}",
|
||||
"data": {
|
||||
"channel": "发布通道",
|
||||
"auto_update": "服务器自动更新",
|
||||
"server_port": "MCP 服务器监听端口",
|
||||
"bind_host": "网络访问",
|
||||
"webhook_auth": "身份验证模式",
|
||||
"oauth_client_id_override": "旧版 OAuth:自定义 Client ID(可选)",
|
||||
"oauth_client_secret_override": "旧版 OAuth:自定义 Client Secret(可选)",
|
||||
"oauth_regenerate": "旧版 OAuth:立即重新生成 Client ID/Secret",
|
||||
"pip_spec": "开发者:ha-mcp 软件包覆盖",
|
||||
"server_url": "Home Assistant URL(高级)",
|
||||
"external_url": "外部 URL(可选)",
|
||||
"webhook_id_override": "自定义 Webhook 密钥(可选)",
|
||||
"secret_path_override": "自定义直连路径(可选)",
|
||||
"regenerate_secrets": "立即重新生成连接密钥",
|
||||
"enable_webhook": "通过 Webhook 远程访问",
|
||||
"enable_llm_api": "对话智能体 LLM API",
|
||||
"llm_api_exposure": "对话智能体工具暴露方式",
|
||||
"enable_startup_notification": "启动通知",
|
||||
"enable_sidebar_panel": "侧边栏设置面板"
|
||||
},
|
||||
"data_description": {
|
||||
"channel": "稳定版安装最新的稳定发行版;开发版安装最新的开发构建。启用自动更新后,重新加载或重启以及定期检查都会安装所选通道的最新构建。下方的开发者软件包覆盖优先级更高,并会禁用自动更新。",
|
||||
"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 仍然有效,新的尚未启用。同时会清空上面两个覆盖字段。",
|
||||
"pip_spec": "请留空。仅用于测试特定的 ha-mcp 构建(例如固定到某个预发行版);它会覆盖发布通道,并在清空之前禁用自动更新。",
|
||||
"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}",
|
||||
"llm_api_exposure": "提供给对话智能体的工具集形态。工具搜索(默认)可让智能体的上下文保持精简:一个包含固定工具以及搜索/执行元工具的紧凑 API。完整目录会直接列出每个已暴露的工具——更适合大上下文模型。两者会将二者并列注册,让每个智能体在“控制 Home Assistant”下各自选择。按工具的暴露设置在 HA-MCP 设置面板中管理;详情:{llm_api_docs_url}",
|
||||
"enable_startup_notification": "每次服务器启动时显示通知,指向仅管理员可见的设置界面。关闭则静默启动——连接 URL 仍会出现在 Home Assistant 日志中。",
|
||||
"enable_sidebar_panel": "在侧边栏中显示 HA-MCP 设置面板(仅管理员)。关闭则移除侧边栏条目——服务器选项仍可在此界面中使用。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"server_start_failed": {
|
||||
"title": "HA-MCP 进程内服务器启动失败",
|
||||
"description": "无法在 Home Assistant 内部启动 HA-MCP 进程内服务器:\n\n{detail}\n\n请检查 Home Assistant 日志,然后重新加载该集成(或先修复根本问题再重新加载)以重试。"
|
||||
},
|
||||
"server_package_install_failed": {
|
||||
"title": "无法安装 HA-MCP 进程内服务器软件包",
|
||||
"description": "为进程内服务器安装 ha-mcp 软件包失败:\n\n{detail}\n\n请解决上述兼容性或安装问题,然后重新加载该集成以重试。"
|
||||
},
|
||||
"component_outdated": {
|
||||
"title": "请通过 HACS 更新 HA-MCP 自定义组件",
|
||||
"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 服务器更新实体上点击“安装”。"
|
||||
},
|
||||
"legacy_hacs_source": {
|
||||
"title": "组件安装自旧版仓库",
|
||||
"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(设置 - 系统 - 重启)以应用更改;在此之前仍保持先前的行为。"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"server_channel": {
|
||||
"options": {
|
||||
"stable": "稳定版(推荐)",
|
||||
"dev": "开发版(最新构建)"
|
||||
}
|
||||
},
|
||||
"server_webhook_auth": {
|
||||
"options": {
|
||||
"none": "密钥 Webhook URL(默认)",
|
||||
"ha_auth": "使用 Home Assistant 登录(OAuth)",
|
||||
"legacy": "旧版 OAuth(Client ID/Secret,适用于 Google Gemini Spark)"
|
||||
}
|
||||
},
|
||||
"llm_api_exposure": {
|
||||
"options": {
|
||||
"tool_search": "工具搜索(紧凑,默认)",
|
||||
"full": "完整目录",
|
||||
"both": "两者(按智能体选择)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"update": {
|
||||
"server_update": {
|
||||
"name": "更新"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"connect_direct_access": "从 Home Assistant 所在主机直接访问:{url}",
|
||||
"connect_local_lan": "本地/局域网(当网络访问为“Local network”时):{url}",
|
||||
"connect_remote_url": "远程连接 URL:{url}",
|
||||
"connect_urls_label": "连接 URL:",
|
||||
"connect_urls_pending": "服务器启动后,连接 URL 将显示在此处(以及 Home Assistant 日志中)。",
|
||||
"connect_webhook_disabled": "通过 Webhook 远程访问已关闭(仅本地模式)。",
|
||||
"oauth_creds_pending": "服务器启动后,Client ID 和 Client Secret 将显示在此处。",
|
||||
"oauth_not_serving": "旧版 OAuth 尚未启用这些凭据——请在 Home Assistant 提示时重启以激活它们。",
|
||||
"oauth_select_legacy_mode": "将上面的身份验证模式设为旧版 OAuth 并保存,以生成 Client ID 和 Client Secret。",
|
||||
"panel_hint": "打开 [HA-MCP 设置面板](/ha-mcp) 以管理工具和服务器设置。",
|
||||
"tools_module_installed": "Beta/高级文件与 YAML 工具模块(可选):已安装",
|
||||
"tools_module_not_installed": "Beta/高级文件与 YAML 工具模块(可选):未安装——请在此集成页面上点击“添加条目”并选择“HA-MCP File & YAML Tools”",
|
||||
"tools_module_not_loaded": "Beta/高级文件与 YAML 工具模块(可选):已安装但未加载——请在此集成页面上启用或重新加载“HA-MCP File & YAML Tools”条目",
|
||||
"version_line": "组件 {component_version} - 服务器 ha-mcp {server_version}({channel} 通道)",
|
||||
"version_not_installed": "尚未安装",
|
||||
"version_unknown": "未知"
|
||||
}
|
||||
}
|
||||
@@ -357,6 +357,13 @@ CAPABILITIES: list[str] = [
|
||||
"backup_prep",
|
||||
"registries",
|
||||
"dashboards",
|
||||
# A flag, not a standalone command: gates the additive whole-document
|
||||
# search-result keys on ``ha_mcp_tools/dashboards`` mode=search
|
||||
# (``document_matches`` + ``yaml_skipped`` + ``load_failed``, issue #2008).
|
||||
# The server's ha_search dashboard bucket routes through the component only
|
||||
# when this is advertised — an older component without the keys would
|
||||
# silently narrow coverage to the card-scoped walk and hide load failures.
|
||||
"dashboards_doc_search",
|
||||
"services_list",
|
||||
"reference_data",
|
||||
# A flag, not a standalone command: gates the optional ``visibility`` param
|
||||
@@ -1274,13 +1281,20 @@ def _search_entities(
|
||||
"""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").
|
||||
state_filter_lower = state_filter.lower() if state_filter is not None else None
|
||||
for state in _iter_states(hass):
|
||||
rec = _entity_record(state, view)
|
||||
if domain_filter and rec["domain"] != domain_filter:
|
||||
continue
|
||||
if rec["_hidden"] and not include_hidden:
|
||||
continue
|
||||
if state_filter is not None and rec["state"] != state_filter:
|
||||
if (
|
||||
state_filter_lower is not None
|
||||
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
|
||||
@@ -1963,8 +1977,7 @@ def _name_tier(query_lower: str, texts: Any, *, exact: bool) -> int | None:
|
||||
if query_norm and query_norm in _sep_normalized(text_lower):
|
||||
return 100
|
||||
ratio = _calc_ratio(query_lower, text_lower)
|
||||
if ratio > best_ratio:
|
||||
best_ratio = ratio
|
||||
best_ratio = max(best_ratio, ratio)
|
||||
if not exact and best_ratio >= FUZZY_THRESHOLD:
|
||||
return best_ratio
|
||||
return None
|
||||
@@ -3192,7 +3205,7 @@ def _exposure_enrichment(
|
||||
rather than crashing the join).
|
||||
"""
|
||||
join = _registry_enrichment(view, entity_id)
|
||||
domain = entity_id.split(".")[0] if "." in entity_id else ""
|
||||
domain = entity_id.split(".", maxsplit=1)[0] if "." in entity_id else ""
|
||||
info: dict[str, Any] = {
|
||||
"domain": domain,
|
||||
"area": join["area"],
|
||||
@@ -4079,6 +4092,15 @@ def _do_dashboards(
|
||||
"available": True,
|
||||
"matches": matches,
|
||||
"truncated": truncated,
|
||||
# ``dashboards_doc_search`` additions (issue #2008): the whole-
|
||||
# document per-dashboard verdicts + honesty counters the server's
|
||||
# ha_search dashboard bucket needs. Additive — a pre-#2008 server
|
||||
# ignores them.
|
||||
"document_matches": _dashboard_document_matches(
|
||||
prepped.get("docs") or [], query_lower
|
||||
),
|
||||
"yaml_skipped": prepped.get("yaml_skipped", 0),
|
||||
"load_failed": prepped.get("load_failed", 0),
|
||||
}
|
||||
return {"mode": "list", "available": True, "dashboards": prepped.get("rows") or []}
|
||||
|
||||
@@ -4101,7 +4123,11 @@ async def _dashboards_prep(hass: HomeAssistant, msg: dict[str, Any]) -> dict[str
|
||||
if mode == "get":
|
||||
prepped.update(await _dashboard_get_config(dashboards_map, msg.get("url_path")))
|
||||
elif mode == "search":
|
||||
prepped["docs"] = await _dashboard_search_docs(dashboards_map)
|
||||
(
|
||||
prepped["docs"],
|
||||
prepped["yaml_skipped"],
|
||||
prepped["load_failed"],
|
||||
) = await _dashboard_search_docs(dashboards_map)
|
||||
else:
|
||||
prepped["rows"] = _dashboard_list_rows(dashboards_map)
|
||||
return {"prepped": prepped}
|
||||
@@ -4191,15 +4217,42 @@ async def _dashboard_get_config(
|
||||
|
||||
async def _dashboard_search_docs(
|
||||
dashboards_map: Mapping[Any, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
) -> tuple[list[dict[str, Any]], int, int]:
|
||||
"""Load every STORAGE dashboard's config for the ``search`` walk.
|
||||
|
||||
Only storage dashboards are loaded — YAML bodies are never searched/emitted.
|
||||
A per-dashboard load error is skipped (fail-soft) rather than failing the
|
||||
whole search. Returns ``[{url_path, title, config}, ...]`` plain dicts.
|
||||
Returns ``(docs, yaml_skipped, load_failed)``: ``docs`` are
|
||||
``[{url_path, title, registry_title, config}, ...]`` plain dicts —
|
||||
``title`` stays the config body's (the card-scoped ``matches`` records pin
|
||||
byte parity with the server's legacy MODE 4 walk on it) while the additive
|
||||
``registry_title`` carries the list-row metadata title that
|
||||
``document_matches`` emits (what the legacy ha_search bucket records
|
||||
carry); ``yaml_skipped`` counts
|
||||
the YAML-mode entries this walk never reads, INCLUDING a default dashboard
|
||||
forced to YAML (``lovelace: mode: yaml``), which has no ``list`` row for
|
||||
the server to count — the server treats a non-zero count as its
|
||||
fall-back-to-legacy signal, since the legacy walk DOES read YAML bodies
|
||||
and coverage must not depend on which path served (issue #2008 review);
|
||||
``load_failed`` counts storage
|
||||
dashboards whose config load raised or returned a non-dict — real gaps the
|
||||
caller must surface as partial rather than fail-soft into a clean-looking
|
||||
result. A ``ConfigNotFound`` load is a clean skip, not a failure: an
|
||||
auto-generated (never taken control of) dashboard has no stored config to
|
||||
scan. If core drift breaks the guarded ``ConfigNotFound`` import, those
|
||||
loads degrade to ``load_failed`` — over-reported as partial, never silent.
|
||||
"""
|
||||
try:
|
||||
from homeassistant.components.lovelace.const import ConfigNotFound
|
||||
except Exception: # pragma: no cover - defensive; core drift
|
||||
ConfigNotFound = None
|
||||
|
||||
docs: list[dict[str, Any]] = []
|
||||
yaml_skipped = 0
|
||||
load_failed = 0
|
||||
for url_path, dash in dashboards_map.items():
|
||||
if _dashboard_mode(dash) == _LOVELACE_MODE_YAML:
|
||||
yaml_skipped += 1
|
||||
continue
|
||||
if _dashboard_mode(dash) != _LOVELACE_MODE_STORAGE:
|
||||
continue
|
||||
loader = getattr(dash, "async_load", None)
|
||||
@@ -4207,19 +4260,29 @@ async def _dashboard_search_docs(
|
||||
continue
|
||||
try:
|
||||
config = await loader(False)
|
||||
except Exception: # skip an unreadable dashboard, keep going (fail-soft)
|
||||
except Exception as err:
|
||||
if ConfigNotFound is not None and isinstance(err, ConfigNotFound):
|
||||
# Auto-generated dashboard: nothing stored, nothing to scan.
|
||||
continue
|
||||
load_failed += 1
|
||||
continue
|
||||
if not isinstance(config, dict):
|
||||
load_failed += 1
|
||||
continue
|
||||
meta = getattr(dash, "config", None)
|
||||
registry_title = meta.get("title") if isinstance(meta, Mapping) else None
|
||||
title = config.get("title")
|
||||
docs.append(
|
||||
{
|
||||
"url_path": url_path,
|
||||
"title": str(title) if title is not None else None,
|
||||
"registry_title": (
|
||||
str(registry_title) if registry_title is not None else None
|
||||
),
|
||||
"config": config,
|
||||
}
|
||||
)
|
||||
return docs
|
||||
return docs, yaml_skipped, load_failed
|
||||
|
||||
|
||||
def _dashboard_mode(dash: Any) -> str | None:
|
||||
@@ -4228,6 +4291,52 @@ def _dashboard_mode(dash: Any) -> str | None:
|
||||
return str(mode) if isinstance(mode, str) else None
|
||||
|
||||
|
||||
def _dashboard_document_matches(
|
||||
docs: list[dict[str, Any]], query_lower: str
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Per-dashboard whole-document verdicts: ``[{url_path, title}, ...]``.
|
||||
|
||||
One entry per doc whose ENTIRE config contains ``query_lower`` — the
|
||||
coverage the server's legacy ``_search_in_dict`` walk provides (view
|
||||
titles, dashboard-level keys, every leaf), which the card-scoped
|
||||
``matches`` walk deliberately narrows to. ``title`` is the registry
|
||||
metadata's (falling back to the body's) — what the legacy ha_search
|
||||
bucket records carry. An empty query matches nothing. Bounded by the
|
||||
dashboard count, so no cap/truncation applies.
|
||||
"""
|
||||
if not query_lower:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"url_path": doc.get("url_path"),
|
||||
"title": doc.get("registry_title") or doc.get("title"),
|
||||
}
|
||||
for doc in docs
|
||||
if _doc_contains(doc.get("config"), query_lower)
|
||||
]
|
||||
|
||||
|
||||
def _doc_contains(data: Any, query_lower: str) -> bool:
|
||||
"""Case-insensitive substring test over keys and every leaf of a config.
|
||||
|
||||
Exact port of the server's ``_search_in_dict_exact`` (keys + string
|
||||
leaves + ``str()`` of non-None scalars) so the component-served verdict
|
||||
matches the legacy walk's, leaf for leaf.
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
return any(
|
||||
query_lower in str(key).lower() or _doc_contains(value, query_lower)
|
||||
for key, value in data.items()
|
||||
)
|
||||
if isinstance(data, list):
|
||||
return any(_doc_contains(item, query_lower) for item in data)
|
||||
if isinstance(data, str):
|
||||
return query_lower in data.lower()
|
||||
if data is not None:
|
||||
return query_lower in str(data).lower()
|
||||
return False
|
||||
|
||||
|
||||
def _search_dashboard_docs(
|
||||
docs: list[dict[str, Any]], query_lower: str
|
||||
) -> tuple[list[dict[str, Any]], bool]:
|
||||
@@ -4631,7 +4740,18 @@ async def _fetch_service_translations(
|
||||
exc_info=True,
|
||||
)
|
||||
return {}
|
||||
return result if isinstance(result, Mapping) else {}
|
||||
if isinstance(result, Mapping):
|
||||
return result
|
||||
# Same visibility as the failure above: discarding the catalog leaves the
|
||||
# service list untranslated, and the type is the only useful clue. Kept in
|
||||
# step with the mirrored seam in config_flow.
|
||||
_LOGGER.warning(
|
||||
"services_list: ignoring the %s service translations: expected a "
|
||||
"Mapping, got %s",
|
||||
language,
|
||||
type(result).__name__,
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
def _filter_service_translations(
|
||||
@@ -5075,7 +5195,7 @@ def _assist_default_exposed(hass: HomeAssistant, entity_id: str) -> bool:
|
||||
or getattr(entry, "hidden_by", None) is not None
|
||||
):
|
||||
return False
|
||||
domain = entity_id.split(".")[0] if "." in entity_id else entity_id
|
||||
domain = entity_id.split(".", maxsplit=1)[0] if "." in entity_id else entity_id
|
||||
if domain in DEFAULT_EXPOSED_DOMAINS:
|
||||
return True
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
@@ -169,6 +169,9 @@ async def async_setup_entry( # noqa: C901
|
||||
await hilo.async_init(scan_interval)
|
||||
except HiloError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
except (TimeoutError, client_exceptions.ClientError) as err:
|
||||
LOG.debug("Timeout with API connection")
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = hilo
|
||||
@@ -198,18 +201,36 @@ async def async_setup_entry( # noqa: C901
|
||||
"sensor.hilo_gateway": "sensor.hilo_gateway",
|
||||
}
|
||||
for old_id, new_id in gateway_entity_renames.items():
|
||||
if old_id != new_id and entity_registry.async_get(old_id):
|
||||
if old_id == new_id or not entity_registry.async_get(old_id):
|
||||
continue
|
||||
if entity_registry.async_get(new_id):
|
||||
LOG.info(
|
||||
"Skipping migration %s -> %s, target entity ID already registered",
|
||||
old_id,
|
||||
new_id,
|
||||
)
|
||||
entity_registry.async_update_entity(old_id, new_entity_id=new_id)
|
||||
LOG.info("Migrated entity ID %s -> %s", old_id, new_id)
|
||||
continue
|
||||
entity_registry.async_update_entity(old_id, new_entity_id=new_id)
|
||||
LOG.info("Migrated entity ID %s -> %s", old_id, new_id)
|
||||
|
||||
# Note (ic-dev21): this new renaming by HA also breaks sensor.hilo_energy_total, which is used in check_tarif
|
||||
energy_entity_renames = {
|
||||
"sensor.meter00_hilo_energy_total": "sensor.hilo_energy_total"
|
||||
}
|
||||
for old_id, new_id in energy_entity_renames.items():
|
||||
if old_id != new_id and entity_registry.async_get(old_id):
|
||||
if old_id == new_id or not entity_registry.async_get(old_id):
|
||||
continue
|
||||
if entity_registry.async_get(new_id):
|
||||
LOG.info(
|
||||
"Skipping migration %s -> %s, target entity ID already registered",
|
||||
old_id,
|
||||
new_id,
|
||||
)
|
||||
entity_registry.async_update_entity(old_id, new_entity_id=new_id)
|
||||
LOG.info("Migrated entity ID %s -> %s", old_id, new_id)
|
||||
continue
|
||||
entity_registry.async_update_entity(old_id, new_entity_id=new_id)
|
||||
LOG.info("Migrated entity ID %s -> %s", old_id, new_id)
|
||||
|
||||
async def handle_debug_event(event: Event):
|
||||
"""Handle an event."""
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"issue_tracker": "https://github.com/dvd-dev/hilo/issues",
|
||||
"requirements": ["python-hilo>=2026.3.5"],
|
||||
"version": "2026.5.2"
|
||||
"version": "2026.8.1"
|
||||
}
|
||||
|
||||
@@ -349,7 +349,18 @@ class EnergySensor(IntegrationSensor):
|
||||
identifiers={(DOMAIN, self._device.identifier)},
|
||||
)
|
||||
|
||||
if Version(current_version) >= Version("2025.8"):
|
||||
if Version(current_version) >= Version("2026.8"):
|
||||
super().__init__(
|
||||
integration_method=METHOD_LEFT,
|
||||
max_sub_interval=timedelta(seconds=MAX_SUB_INTERVAL),
|
||||
name=self._attr_name,
|
||||
round_digits=2,
|
||||
source_entity=self._source,
|
||||
unique_id=self._attr_unique_id,
|
||||
unit_prefix="k",
|
||||
unit_time="h",
|
||||
)
|
||||
elif Version(current_version) >= Version("2025.8"):
|
||||
super().__init__(
|
||||
hass,
|
||||
integration_method=METHOD_LEFT,
|
||||
|
||||
@@ -158,6 +158,7 @@ class NotificationCoordinator:
|
||||
|
||||
recipients_fired: list[str] = []
|
||||
message = self._render_template(meta, context)
|
||||
nav_url = self._resolve_nav_url(cfg)
|
||||
|
||||
# Multi-parent routing (#687): thin the PARENT recipients down per the
|
||||
# configured policy. Child routes are never touched — a reminder for a
|
||||
@@ -179,7 +180,7 @@ class NotificationCoordinator:
|
||||
notify_service = self._resolve_notify_service(recipient_id)
|
||||
if not notify_service:
|
||||
continue
|
||||
await self._send_to(notify_service, message, meta, context)
|
||||
await self._send_to(notify_service, message, meta, context, nav_url)
|
||||
recipients_fired.append(recipient_id)
|
||||
|
||||
# NOTE: deliberately no unconditional persistent_notification here.
|
||||
@@ -283,6 +284,7 @@ class NotificationCoordinator:
|
||||
}
|
||||
message = "[TEST] " + self._render_template(meta, ctx)
|
||||
cfg = self.storage.get_notification_config(type_id)
|
||||
nav_url = self._resolve_nav_url(cfg)
|
||||
sent: list[str] = []
|
||||
for recipient_id, route in cfg.routes.items():
|
||||
if not route.enabled:
|
||||
@@ -290,7 +292,7 @@ class NotificationCoordinator:
|
||||
notify_service = self._resolve_notify_service(recipient_id)
|
||||
if not notify_service:
|
||||
continue
|
||||
await self._send_to(notify_service, message, meta, ctx)
|
||||
await self._send_to(notify_service, message, meta, ctx, nav_url)
|
||||
sent.append(recipient_id)
|
||||
await self._fire_persistent_notification(type_id, message)
|
||||
return sent
|
||||
@@ -308,6 +310,13 @@ class NotificationCoordinator:
|
||||
child.quiet_hours_start, child.quiet_hours_end, dt_util.now()
|
||||
)
|
||||
|
||||
def _resolve_nav_url(self, cfg) -> str:
|
||||
"""Tap target for this notification: per-type override, else global default."""
|
||||
per_type = (getattr(cfg, "nav_url", "") or "").strip()
|
||||
if per_type:
|
||||
return per_type
|
||||
return str(self.storage.get_setting("notification_nav_url", "/taskmate") or "").strip()
|
||||
|
||||
def _resolve_notify_service(self, recipient_id: str) -> str:
|
||||
if recipient_id.startswith("child:"):
|
||||
child_id = recipient_id.split(":", 1)[1]
|
||||
@@ -349,7 +358,7 @@ class NotificationCoordinator:
|
||||
|
||||
async def _send_to(
|
||||
self, notify_service: str, message: str,
|
||||
meta: "NotificationTypeMeta", context: dict[str, Any],
|
||||
meta: "NotificationTypeMeta", context: dict[str, Any], nav_url: str = "",
|
||||
) -> None:
|
||||
domain, service = (
|
||||
notify_service.split(".", 1) if "." in notify_service
|
||||
@@ -395,6 +404,13 @@ class NotificationCoordinator:
|
||||
push["image"] = photo_url
|
||||
push["attachment"] = {"url": photo_url, "content-type": "jpeg"}
|
||||
|
||||
# Tap target (#734): open a chosen place when the notification body is
|
||||
# tapped. clickAction=Android, url=iOS — the same value works on both.
|
||||
# Only the mobile app honours these; other backends ignore data, so skip.
|
||||
if nav_url and service.startswith("mobile_app"):
|
||||
push["clickAction"] = nav_url
|
||||
push["url"] = nav_url
|
||||
|
||||
if push:
|
||||
data["data"] = push
|
||||
|
||||
@@ -693,6 +709,15 @@ class NotificationCoordinator:
|
||||
await self.storage.async_save()
|
||||
await self.async_setup_schedules()
|
||||
|
||||
async def set_nav_url(self, type_id: str | None, nav_url: str) -> None:
|
||||
"""Set the tap target — global (type_id falsy) or per notification type."""
|
||||
nav_url = (nav_url or "").strip()
|
||||
if type_id:
|
||||
self.storage.set_notification_nav_url(type_id, nav_url)
|
||||
else:
|
||||
self.storage.set_setting("notification_nav_url", nav_url)
|
||||
await self.storage.async_save()
|
||||
|
||||
def _has_outstanding_chores_today(self, child_id: str) -> bool:
|
||||
"""Returns True if the child has at least one chore assigned today
|
||||
that has no approved/pending completion yet."""
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
"iot_class": "calculated",
|
||||
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
|
||||
"requirements": [],
|
||||
"version": "5.0.1"
|
||||
"version": "5.0.2"
|
||||
}
|
||||
|
||||
@@ -1194,6 +1194,7 @@ class NotificationConfig:
|
||||
type_id: str
|
||||
master_enabled: bool = False
|
||||
routes: dict[str, NotificationRoute] = field(default_factory=dict)
|
||||
nav_url: str = "" # tap target; "" = inherit global default
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> NotificationConfig:
|
||||
@@ -1205,14 +1206,18 @@ class NotificationConfig:
|
||||
rid: NotificationRoute.from_dict(rdata)
|
||||
for rid, rdata in raw_routes.items()
|
||||
},
|
||||
nav_url=data.get("nav_url", "") or "",
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
d: dict[str, Any] = {
|
||||
"type_id": self.type_id,
|
||||
"master_enabled": self.master_enabled,
|
||||
"routes": {rid: r.to_dict() for rid, r in self.routes.items()},
|
||||
}
|
||||
if self.nav_url:
|
||||
d["nav_url"] = self.nav_url
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -728,6 +728,11 @@ class TaskMateStorage:
|
||||
cfg.master_enabled = enabled
|
||||
self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict()
|
||||
|
||||
def set_notification_nav_url(self, type_id: str, nav_url: str) -> None:
|
||||
cfg = self.get_notification_config(type_id)
|
||||
cfg.nav_url = nav_url
|
||||
self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict()
|
||||
|
||||
def set_notification_route(
|
||||
self, type_id: str, recipient_id: str, route: NotificationRoute
|
||||
) -> None:
|
||||
|
||||
@@ -154,6 +154,7 @@ WS_NOTIF_LIST_NOTIFY: Final = "taskmate/notifications/list_notify_service
|
||||
WS_NOTIF_SET_STREAK_CUTOFF: Final = "taskmate/notifications/set_streak_cutoff"
|
||||
WS_NOTIF_SET_ESCALATION: Final = "taskmate/notifications/set_escalation"
|
||||
WS_NOTIF_SEND_TEST: Final = "taskmate/notifications/send_test"
|
||||
WS_NOTIF_SET_NAV_URL: Final = "taskmate/notifications/set_nav_url"
|
||||
|
||||
# Calendar ICS feed (FEAT-10)
|
||||
WS_CAL_GET_URL: Final = "taskmate/calendar/get_ics_url"
|
||||
@@ -1909,6 +1910,7 @@ async def ws_notif_get_state(hass, connection, msg, coordinator):
|
||||
"streak_at_risk_cutoff_time": c.storage.get_streak_at_risk_cutoff(),
|
||||
"mandatory_escalation_reminder_minutes": c.storage.get_escalation_reminder_minutes(),
|
||||
"mandatory_escalation_parent_minutes": c.storage.get_escalation_parent_minutes(),
|
||||
"notification_nav_url": c.storage.get_setting("notification_nav_url", "/taskmate"),
|
||||
},
|
||||
}
|
||||
connection.send_result(msg["id"], state)
|
||||
@@ -2135,6 +2137,18 @@ async def ws_notif_send_test(hass, connection, msg, coordinator):
|
||||
connection.send_result(msg["id"], {"sent": sent})
|
||||
|
||||
|
||||
@websocket_api.websocket_command({
|
||||
vol.Required("type"): WS_NOTIF_SET_NAV_URL,
|
||||
vol.Optional("type_id"): vol.Any(str, None),
|
||||
vol.Required("nav_url"): vol.All(str, vol.Length(max=200)),
|
||||
})
|
||||
@websocket_api.async_response
|
||||
@_admin_only
|
||||
async def ws_notif_set_nav_url(hass, connection, msg, coordinator):
|
||||
await coordinator.notifications.set_nav_url(msg.get("type_id"), msg["nav_url"])
|
||||
connection.send_result(msg["id"], {"ok": True})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Calendar ICS feed (FEAT-10)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2323,7 +2337,7 @@ _COMMANDS = (
|
||||
ws_notif_upsert_parent, ws_notif_delete_parent,
|
||||
ws_notif_upsert_custom, ws_notif_delete_custom,
|
||||
ws_notif_list_notify, ws_notif_set_streak_cutoff, ws_notif_send_test,
|
||||
ws_notif_set_escalation,
|
||||
ws_notif_set_escalation, ws_notif_set_nav_url,
|
||||
ws_cal_get_url, ws_cal_regen_token,
|
||||
)
|
||||
|
||||
|
||||
@@ -866,6 +866,9 @@
|
||||
"panel.notif_section_recipients": "Empfänger",
|
||||
"panel.notif_section_recipients_desc": "Weise jedem Kind und Elternteil einen Home Assistant Benachrichtigungsdienst zu. Leer lassen, um Benachrichtigungen für diese Person zu überspringen.",
|
||||
"panel.notif_streak_cutoff_label": "Frist",
|
||||
"panel.notif_nav_url_global_label": "Beim Tippen öffnen",
|
||||
"panel.notif_nav_url_global_hint": "Wohin eine angetippte Benachrichtigung führt: ein Pfad wie /taskmate oder /lovelace/parents, eine vollständige URL oder noAction. Gilt für alle Benachrichtigungen, sofern unten nicht pro Typ überschrieben.",
|
||||
"panel.notif_nav_url_row_placeholder": "Nutzt globalen Standard",
|
||||
"panel.notif_tab_title": "Benachrichtigungen",
|
||||
"panel.template_assignment_mode_label": "Zuweisungsmodus",
|
||||
"panel.template_builtin": "Integriert",
|
||||
|
||||
@@ -916,6 +916,9 @@
|
||||
"panel.notif_section_recipients": "Recipients",
|
||||
"panel.notif_section_recipients_desc": "Map each child and parent to a Home Assistant notify service. Leave blank to skip notifications for that person.",
|
||||
"panel.notif_streak_cutoff_label": "Cutoff",
|
||||
"panel.notif_nav_url_global_label": "When tapped, open",
|
||||
"panel.notif_nav_url_global_hint": "Where a tapped notification opens: a path like /taskmate or /lovelace/parents, a full URL, or noAction. Applies to all notifications unless overridden per type below.",
|
||||
"panel.notif_nav_url_row_placeholder": "Uses global default",
|
||||
"panel.notif_send_test": "Send test",
|
||||
"panel.notif_test_sent": "Test sent to {count} recipient(s) + persistent notification",
|
||||
"panel.notif_tab_title": "Notifications",
|
||||
|
||||
@@ -916,6 +916,9 @@
|
||||
"panel.notif_section_recipients": "Recipients",
|
||||
"panel.notif_section_recipients_desc": "Map each child and parent to a Home Assistant notify service. Leave blank to skip notifications for that person.",
|
||||
"panel.notif_streak_cutoff_label": "Cutoff",
|
||||
"panel.notif_nav_url_global_label": "When tapped, open",
|
||||
"panel.notif_nav_url_global_hint": "Where a tapped notification opens: a path like /taskmate or /lovelace/parents, a full URL, or noAction. Applies to all notifications unless overridden per type below.",
|
||||
"panel.notif_nav_url_row_placeholder": "Uses global default",
|
||||
"panel.notif_send_test": "Send test",
|
||||
"panel.notif_test_sent": "Test sent to {count} recipient(s) + persistent notification",
|
||||
"panel.notif_tab_title": "Notifications",
|
||||
|
||||
@@ -866,6 +866,9 @@
|
||||
"panel.notif_section_recipients": "Destinataires",
|
||||
"panel.notif_section_recipients_desc": "Associez chaque enfant et parent à un service de notification Home Assistant. Laisser vide pour ignorer les notifications pour cette personne.",
|
||||
"panel.notif_streak_cutoff_label": "Limite",
|
||||
"panel.notif_nav_url_global_label": "Au toucher, ouvrir",
|
||||
"panel.notif_nav_url_global_hint": "Où mène une notification touchée : un chemin comme /taskmate ou /lovelace/parents, une URL complète ou noAction. S'applique à toutes les notifications sauf remplacement par type ci-dessous.",
|
||||
"panel.notif_nav_url_row_placeholder": "Utilise la valeur globale",
|
||||
"panel.notif_tab_title": "Notifications",
|
||||
"panel.template_assignment_mode_label": "Mode d'assignation",
|
||||
"panel.template_builtin": "Intégré",
|
||||
|
||||
@@ -866,6 +866,9 @@
|
||||
"panel.notif_section_recipients": "Mottakere",
|
||||
"panel.notif_section_recipients_desc": "Koble hvert barn og foresatt til en Home Assistant-varslingstjeneste. La stå tomt for å hoppe over varsler for den personen.",
|
||||
"panel.notif_streak_cutoff_label": "Grense",
|
||||
"panel.notif_nav_url_global_label": "Ved trykk, åpne",
|
||||
"panel.notif_nav_url_global_hint": "Hvor et trykk på et varsel åpner: en sti som /taskmate eller /lovelace/parents, en full URL, eller noAction. Gjelder alle varsler med mindre det overstyres per type nedenfor.",
|
||||
"panel.notif_nav_url_row_placeholder": "Bruker global standard",
|
||||
"panel.notif_tab_title": "Varsler",
|
||||
"panel.template_assignment_mode_label": "Tildelingsmodus",
|
||||
"panel.template_builtin": "Innebygd",
|
||||
|
||||
@@ -866,6 +866,9 @@
|
||||
"panel.notif_section_recipients": "Mottakarar",
|
||||
"panel.notif_section_recipients_desc": "Kople kvart born og kvar føresett til ein Home Assistant-varslingsteneste. La stå tomt for å hoppa over varsel for den personen.",
|
||||
"panel.notif_streak_cutoff_label": "Grense",
|
||||
"panel.notif_nav_url_global_label": "Ved trykk, opne",
|
||||
"panel.notif_nav_url_global_hint": "Kvar eit trykk på eit varsel opnar: ein sti som /taskmate eller /lovelace/parents, ein full URL, eller noAction. Gjeld alle varsel med mindre det vert overstyrt per type nedanfor.",
|
||||
"panel.notif_nav_url_row_placeholder": "Brukar global standard",
|
||||
"panel.notif_tab_title": "Varsel",
|
||||
"panel.template_assignment_mode_label": "Tildelingsmodus",
|
||||
"panel.template_builtin": "Innebygd",
|
||||
|
||||
@@ -866,6 +866,9 @@
|
||||
"panel.notif_section_recipients": "Destinatários",
|
||||
"panel.notif_section_recipients_desc": "Associe cada criança e responsável a um serviço de notificação do Home Assistant. Deixe em branco para pular notificações para essa pessoa.",
|
||||
"panel.notif_streak_cutoff_label": "Limite",
|
||||
"panel.notif_nav_url_global_label": "Ao tocar, abrir",
|
||||
"panel.notif_nav_url_global_hint": "Para onde uma notificação tocada abre: um caminho como /taskmate ou /lovelace/parents, uma URL completa ou noAction. Aplica-se a todas as notificações, exceto se substituída por tipo abaixo.",
|
||||
"panel.notif_nav_url_row_placeholder": "Usa o padrão global",
|
||||
"panel.notif_tab_title": "Notificações",
|
||||
"panel.template_assignment_mode_label": "Modo de atribuição",
|
||||
"panel.template_builtin": "Integrado",
|
||||
|
||||
@@ -871,6 +871,9 @@
|
||||
"panel.notif_section_recipients": "Destinatários",
|
||||
"panel.notif_section_recipients_desc": "Associe cada criança e encarregado a um serviço de notificação do Home Assistant. Deixe em branco para ignorar notificações para essa pessoa.",
|
||||
"panel.notif_streak_cutoff_label": "Limite",
|
||||
"panel.notif_nav_url_global_label": "Ao tocar, abrir",
|
||||
"panel.notif_nav_url_global_hint": "Para onde uma notificação tocada abre: um caminho como /taskmate ou /lovelace/parents, um URL completo ou noAction. Aplica-se a todas as notificações, salvo substituição por tipo abaixo.",
|
||||
"panel.notif_nav_url_row_placeholder": "Usa o padrão global",
|
||||
"panel.notif_tab_title": "Notificações",
|
||||
"panel.template_assignment_mode_label": "Modo de atribuição",
|
||||
"panel.template_builtin": "Incorporado",
|
||||
|
||||
@@ -676,6 +676,8 @@ class TaskMatePanel extends HTMLElement {
|
||||
if (act === "notif-set-parent-notify"){ /* handled in _onChange */ return; }
|
||||
if (act === "notif-rename-parent") { /* handled in _onChange */ return; }
|
||||
if (act === "notif-set-streak-cutoff"){ /* handled in _onChange */ return; }
|
||||
if (act === "notif-set-nav-url-global"){ /* handled in _onChange */ return; }
|
||||
if (act === "notif-set-nav-url-type") { /* handled in _onChange */ return; }
|
||||
if (act === "notif-add-parent") { this._notifAddParent(); return; }
|
||||
if (act === "notif-delete-parent") { this._notifDeleteParent(t.dataset.parentId); return; }
|
||||
if (act === "notif-add-custom") { this._notifAddCustom(); return; }
|
||||
@@ -915,6 +917,14 @@ class TaskMatePanel extends HTMLElement {
|
||||
this._notifSetStreakCutoff(t.value);
|
||||
return;
|
||||
}
|
||||
if (t.dataset.act === "notif-set-nav-url-global") {
|
||||
this._notifSetNavUrl(null, t.value);
|
||||
return;
|
||||
}
|
||||
if (t.dataset.act === "notif-set-nav-url-type") {
|
||||
this._notifSetNavUrl(t.dataset.typeId, t.value);
|
||||
return;
|
||||
}
|
||||
if (t.dataset.act === "notif-set-escalation") {
|
||||
this._notifSetEscalation(t.dataset.escField, t.value);
|
||||
return;
|
||||
@@ -2100,6 +2110,13 @@ class TaskMatePanel extends HTMLElement {
|
||||
await this._fetchState();
|
||||
}
|
||||
|
||||
async _notifSetNavUrl(typeId, navUrl) {
|
||||
const payload = { type: "taskmate/notifications/set_nav_url", nav_url: navUrl || "" };
|
||||
if (typeId) payload.type_id = typeId;
|
||||
await this._callWS(payload);
|
||||
await this._fetchState();
|
||||
}
|
||||
|
||||
async _notifSetEscalation(field, value) {
|
||||
const s = (this._notifState && this._notifState.settings) || {};
|
||||
const cur = {
|
||||
@@ -4578,6 +4595,13 @@ class TaskMatePanel extends HTMLElement {
|
||||
<div class="tm-card" style="margin-bottom:16px">
|
||||
<h3>${this._t("panel.notif_section_matrix")}</h3>
|
||||
<p class="tm-meta">${this._t("panel.notif_section_matrix_desc")}</p>
|
||||
<div class="tm-meta" style="margin:4px 0 12px;display:flex;flex-wrap:wrap;align-items:center;gap:8px">
|
||||
<label style="font-weight:500">${this._t("panel.notif_nav_url_global_label")}</label>
|
||||
<input type="text" class="tm-input" style="flex:1;min-width:180px"
|
||||
value="${this._esc((ns.settings && ns.settings.notification_nav_url) || "")}"
|
||||
data-act="notif-set-nav-url-global" placeholder="/taskmate">
|
||||
<div class="tm-meta" style="flex-basis:100%">${this._t("panel.notif_nav_url_global_hint")}</div>
|
||||
</div>
|
||||
<div class="tm-table-wrap">
|
||||
<table class="tm-table">
|
||||
<thead>
|
||||
@@ -4630,6 +4654,12 @@ class TaskMatePanel extends HTMLElement {
|
||||
<ha-icon icon="mdi:send" style="--mdc-icon-size:14px"></ha-icon> ${this._t("panel.notif_send_test")}
|
||||
</button>
|
||||
</div>
|
||||
<div style="margin-top:6px;display:flex;align-items:center;gap:8px">
|
||||
<input type="text" class="tm-notif-time-input" style="width:150px"
|
||||
value="${this._esc(c.nav_url || "")}"
|
||||
data-act="notif-set-nav-url-type" data-type-id="${this._esc(t.id)}"
|
||||
placeholder="${this._t("panel.notif_nav_url_row_placeholder")}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
+28
-28
@@ -4,7 +4,7 @@
|
||||
"state": "ON",
|
||||
"led_brightness": 100,
|
||||
"countdown_to_turn_off": 0,
|
||||
"voltage": 120.8,
|
||||
"voltage": 120.6,
|
||||
"countdown_to_turn_on": 0,
|
||||
"ac_frequency": 60,
|
||||
"power_factor": 0.14,
|
||||
@@ -15,22 +15,22 @@
|
||||
"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": 0.4,
|
||||
"power": 0.5,
|
||||
"linkquality": 120,
|
||||
"current": 0.03,
|
||||
"power_on_behavior": "on"
|
||||
},
|
||||
"0xffffb40e0607af27": {
|
||||
"state": "ON",
|
||||
"voltage": 118.9,
|
||||
"voltage": 120.3,
|
||||
"ac_frequency": 60,
|
||||
"led_brightness": 100,
|
||||
"countdown_to_turn_off": 0,
|
||||
"countdown_to_turn_on": 0,
|
||||
"power": 2.1,
|
||||
"current": 0.12,
|
||||
"energy": 25.28,
|
||||
"power_factor": 0.15,
|
||||
"power": 96,
|
||||
"current": 1.93,
|
||||
"energy": 25.33,
|
||||
"power_factor": 0.41,
|
||||
"update": {
|
||||
"state": "idle",
|
||||
"installed_version": 268513381,
|
||||
@@ -45,10 +45,10 @@
|
||||
"state": "ON",
|
||||
"led_brightness": 100,
|
||||
"countdown_to_turn_off": 0,
|
||||
"voltage": 120.4,
|
||||
"voltage": 121,
|
||||
"countdown_to_turn_on": 0,
|
||||
"energy": 45.75,
|
||||
"power_factor": 0.2,
|
||||
"energy": 45.77,
|
||||
"power_factor": 0.89,
|
||||
"ac_frequency": 60,
|
||||
"update": {
|
||||
"state": "idle",
|
||||
@@ -58,8 +58,8 @@
|
||||
"latest_release_notes": null
|
||||
},
|
||||
"linkquality": 83,
|
||||
"power": 0.2,
|
||||
"current": 0.01,
|
||||
"power": 84,
|
||||
"current": 0.83,
|
||||
"power_on_behavior": "on"
|
||||
},
|
||||
"0xb40e060fffe031e3": {
|
||||
@@ -74,13 +74,13 @@
|
||||
"led_brightness": 100,
|
||||
"countdown_to_turn_off": 0,
|
||||
"countdown_to_turn_on": 0,
|
||||
"voltage": 118.5,
|
||||
"voltage": 119.4,
|
||||
"state": "ON",
|
||||
"ac_frequency": 60,
|
||||
"energy": 92.51,
|
||||
"power": 98.6,
|
||||
"current": 0.86,
|
||||
"power_factor": 0.49,
|
||||
"energy": 92.55,
|
||||
"power": 98.2,
|
||||
"current": 0.87,
|
||||
"power_factor": 0.94,
|
||||
"update": {
|
||||
"state": "idle",
|
||||
"installed_version": 268513381,
|
||||
@@ -95,13 +95,13 @@
|
||||
"led_brightness": 100,
|
||||
"countdown_to_turn_off": 0,
|
||||
"countdown_to_turn_on": 0,
|
||||
"voltage": 121,
|
||||
"energy": 38.81,
|
||||
"voltage": 120.5,
|
||||
"energy": 38.84,
|
||||
"state": "ON",
|
||||
"power": 39.7,
|
||||
"current": 0.44,
|
||||
"power": 58.5,
|
||||
"current": 0.65,
|
||||
"ac_frequency": 60,
|
||||
"power_factor": 0.74,
|
||||
"power_factor": 0.75,
|
||||
"update": {
|
||||
"state": "idle",
|
||||
"installed_version": 268513381,
|
||||
@@ -114,12 +114,12 @@
|
||||
},
|
||||
"0xffffb40e060895b3": {
|
||||
"state": "ON",
|
||||
"voltage": 120.7,
|
||||
"voltage": 120.5,
|
||||
"ac_frequency": 60,
|
||||
"energy": 5.81,
|
||||
"current": 0.01,
|
||||
"power": 0.1,
|
||||
"power_factor": 0.11,
|
||||
"power_factor": 0.22,
|
||||
"linkquality": 109,
|
||||
"update": {
|
||||
"state": "idle",
|
||||
@@ -136,7 +136,7 @@
|
||||
"0xffffb40e0608864e": {
|
||||
"led_brightness": 100,
|
||||
"countdown_to_turn_off": 0,
|
||||
"voltage": 120,
|
||||
"voltage": 121.2,
|
||||
"energy": 16.85,
|
||||
"countdown_to_turn_on": 0,
|
||||
"state": "ON",
|
||||
@@ -172,7 +172,7 @@
|
||||
"0xffffb40e060893d8": {
|
||||
"state": "ON",
|
||||
"led_brightness": 100,
|
||||
"voltage": 120.8,
|
||||
"voltage": 121.4,
|
||||
"countdown_to_turn_off": 0,
|
||||
"countdown_to_turn_on": 0,
|
||||
"energy": 3,
|
||||
@@ -188,7 +188,7 @@
|
||||
"latest_release_notes": null
|
||||
},
|
||||
"power_factor": 0,
|
||||
"power": 1
|
||||
"power": 0.4
|
||||
},
|
||||
"0xa4c1380d0679ffff": {
|
||||
"battery": 100,
|
||||
@@ -245,7 +245,7 @@
|
||||
},
|
||||
"0xb40e060fffe717c5": {
|
||||
"battery": 100,
|
||||
"occupancy": false,
|
||||
"occupancy": true,
|
||||
"tamper": false,
|
||||
"battery_low": false,
|
||||
"linkquality": 65,
|
||||
|
||||
Reference in New Issue
Block a user