This commit is contained in:
Home Assistant Version Control
2026-09-07 02:30:29 +00:00
parent ea4802bf92
commit 95214c3cf7
11 changed files with 910 additions and 360 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"items": [
{
"id": "cc55565add58413bbcadba9f9cf8caba",
"url": "/hacsfiles/lovelace-mushroom/mushroom.js?hacstag=444350375522",
"url": "/hacsfiles/lovelace-mushroom/mushroom.js?hacstag=444350375523",
"type": "module"
},
{
+7 -2
View File
@@ -25,7 +25,7 @@ DOMAIN = "ha_mcp_tools"
# in CI. The
# capability negotiation — not this version — gates each WS command (see
# ``websocket_api.CAPABILITIES``).
COMPONENT_VERSION = "2.1.2"
COMPONENT_VERSION = "2.1.3"
# Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value
# means "tools" so the pre-existing services entry keeps working across the
@@ -39,7 +39,12 @@ ENTRY_TYPE_SERVER = "server"
# carrying the legacy default (a user-customized title is left alone).
TOOLS_ENTRY_TITLE = "HA-MCP File & YAML Tools"
TOOLS_ENTRY_LEGACY_TITLE = "HA MCP Tools"
MIN_EMBEDDED_HOME_ASSISTANT_VERSION = "2026.6.0"
# Kept level with the HACS floor in hacs.json: from component 2.1.3 the manifest
# declares voluptuous-openapi, which Core releases before 2026.7 pin to an older
# version so the requirement cannot resolve there, and
# that requirement gates the whole integration before any entry's config flow
# runs, so a lower runtime floor here would promise what the load cannot keep.
MIN_EMBEDDED_HOME_ASSISTANT_VERSION = "2026.8.0"
# Allowed directories for file operations (relative to config dir).
# "blueprints" is read-only BY DEFAULT — in ALLOWED_READ_DIRS but not
+240 -8
View File
@@ -47,11 +47,14 @@ stateless session each — the in-process server serves ``stateless_http=True``.
from __future__ import annotations
import asyncio
import copy
import importlib
import logging
import math
from collections import Counter
from collections.abc import AsyncIterator, Callable, Iterable
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass
from dataclasses import dataclass, field
from functools import cache
from typing import TYPE_CHECKING, Any, cast
@@ -138,6 +141,17 @@ def _schema_converter() -> Callable[[Any], Any]:
if err.name != "voluptuous_openapi":
raise
probatio = importlib.import_module("probatio")
_LOGGER.warning(
"voluptuous-openapi is not importable; converting tool schemas "
"with probatio instead. The manifest declares it, so this is "
"Home Assistant running with skip_pip, or a deps tree that lost "
"the package after setup. A requirement that genuinely failed to "
"install never reaches this line -- Home Assistant reports that "
"itself and abandons the integration before importing it. "
"Probatio's OpenAPI codec cannot express the node it builds for "
"an integer, so a numeric parameter can reach the conversation "
"agent as a string, an empty schema or a plain number."
)
return cast(Callable[[Any], Any], probatio.from_openapi)
return cast(Callable[[Any], Any], legacy.convert_to_voluptuous)
@@ -147,6 +161,206 @@ def convert_to_voluptuous(schema: Any) -> vol.Schema:
return cast(vol.Schema, _schema_converter()(schema))
# Keywords whose value is a mapping keyed by author-chosen names — property
# names, definition names, regexes — rather than by JSON Schema keywords. A key
# spelled like a bound is one of those names and must not be rewritten.
_SCHEMA_MAPS: frozenset[str] = frozenset(
{
"properties",
"patternProperties",
"$defs",
"definitions",
"dependentSchemas",
"dependentRequired",
# Draft-7's predecessor of the two above; still emitted by some servers.
"dependencies",
}
)
# Keywords whose value is instance data rather than a subschema. A key spelled
# like a bound inside one of these is a value in someone's default, not a bound.
# ``example`` is OpenAPI's singular spelling of ``examples``.
_INSTANCE_VALUES: frozenset[str] = frozenset(
{"default", "const", "enum", "examples", "example"}
)
# Keywords whose value is neither a subschema nor a map of subschemas.
# OpenAPI's ``discriminator`` holds a ``propertyName`` and a ``mapping`` of
# author-chosen tags to ``$ref`` strings; a tag spelled like a bound is one of
# those names, and its ``$ref`` value would read as a non-numeric bound and be
# dropped, corrupting a valid discriminated union. OpenAPI ``x-`` specification
# extensions carry arbitrary vendor objects the same way and are matched by
# prefix in ``_is_opaque_key``.
_OPAQUE_KEYWORDS: frozenset[str] = frozenset({"discriminator"})
def _is_opaque_key(key: str) -> bool:
"""True for a keyword whose value must be copied through untouched."""
return key in _OPAQUE_KEYWORDS or key.startswith("x-")
# Each exclusive keyword, the inclusive twin it folds into, the picker that
# keeps the tighter of the two when both are present, and the step to the
# nearest integer the bound still admits.
_EXCLUSIVE_BOUNDS: tuple[
tuple[str, str, Callable[[Any, Any], Any], Callable[[Any], int]], ...
] = (
("exclusiveMinimum", "minimum", max, lambda bound: math.floor(bound) + 1),
("exclusiveMaximum", "maximum", min, lambda bound: math.ceil(bound) - 1),
)
@dataclass
class _Rewrite:
"""What one schema normalisation changed, so a caller can log it once.
The walk is recursive and knows no tool name; collecting here lets the
entry point say what happened to which tool in a single line, rather than
once per node or not at all.
"""
folded: list[str] = field(default_factory=list)
dropped: list[str] = field(default_factory=list)
def _to_inclusive_bounds(schema: Any, rewrite: _Rewrite | None = None) -> Any:
"""Return the schema with every exclusive numeric bound made inclusive.
Home Assistant re-emits the converted schema through Probatio's OpenAPI
codec, which defaults to OpenAPI 3.0 and writes an exclusive bound the
Draft-4 way: ``minimum`` beside ``exclusiveMinimum: true``. The Anthropic
API validates ``input_schema`` as JSON Schema draft 2020-12, where
``exclusiveMinimum`` must be a number, and rejects the entire request — so
one such bound anywhere in the mirrored toolset fails every conversation
turn, not just calls to the tool carrying it.
On an integer-only node the fold is exact -- ``exclusiveMinimum: 1``
becomes ``minimum: 2``, the smallest integer the bound admits. Elsewhere
the advertised edge widens by one representable point; the server still
enforces the real bound when the call arrives, so nothing becomes callable
that was not callable before. A boolean is the Draft-4 flag rather than a
bound and is dropped outright.
Normalising here rather than only at the source covers the server versions
this component does not control: the ha-mcp package installs and updates
independently of the component, and an explicit pip-spec pins it outright.
"""
if isinstance(schema, list):
return [_to_inclusive_bounds(item, rewrite) for item in schema]
if not isinstance(schema, dict):
return schema
result: dict[str, Any] = {}
for key, value in schema.items():
if key in _INSTANCE_VALUES or _is_opaque_key(key):
# Copied, not aliased: the result is handed to Core and kept in
# the search catalog, and neither may reach back into the MCP
# result object this schema came from.
result[key] = copy.deepcopy(value)
elif key in _SCHEMA_MAPS and isinstance(value, dict):
result[key] = {
name: _to_inclusive_bounds(sub, rewrite) for name, sub in value.items()
}
else:
result[key] = _to_inclusive_bounds(value, rewrite)
_fold_bounds(result, rewrite)
return result
def _is_number(value: Any) -> bool:
"""True for a JSON number. bool is an int subclass and is not one."""
return not isinstance(value, bool) and isinstance(value, int | float)
def _admits_integers_only(node: dict[str, Any]) -> bool:
"""True when the node's declared type accepts integers and nothing wider.
A union that also admits ``number`` is not tightened: the excluded edge is
still reachable there as a fraction.
"""
declared = node.get("type")
if isinstance(declared, str):
return declared == "integer"
if isinstance(declared, list):
return "integer" in declared and "number" not in declared
return False
def _fold_bounds(node: dict[str, Any], rewrite: _Rewrite | None = None) -> None:
"""Fold each exclusive keyword into its inclusive twin, in place."""
for exclusive, inclusive, tighter, to_integer in _EXCLUSIVE_BOUNDS:
if exclusive not in node:
continue
bound = node.pop(exclusive)
if not _is_number(bound):
# Nothing non-numeric is a readable bound in a subschema slot, and
# the slot is all this can be: the name maps, the instance-value
# keywords and the opaque ones above keep foreign data out of
# reach. Probatio refuses a string, a list and the Draft-4 boolean
# outright -- costing the whole tool -- and silently retypes a null
# number param to a string one. Dropping the key repairs all four.
if rewrite is not None:
rewrite.dropped.append(exclusive)
continue
# On an integer-only node the exact inclusive equivalent exists, so
# take it: folding ``exclusiveMinimum: 1`` to ``minimum: 1`` would
# advertise a value the server rejects.
if _admits_integers_only(node):
bound = to_integer(bound)
# ``tighter`` when the schema already carried an inclusive bound, so
# that bound is never loosened to the exclusive one.
current = node.get(inclusive)
node[inclusive] = tighter(current, bound) if _is_number(current) else bound
if rewrite is not None:
rewrite.folded.append(exclusive)
def _tally(keywords: list[str]) -> str:
"""Render the rewritten keywords with a count, so two nodes read as two.
One line per tool keeps the log readable, but a bare set of keyword names
would make a schema with several malformed bounds indistinguishable from
one carrying a single bad node -- and the count is the only hint left that
more than one place needs fixing.
"""
return ", ".join(
keyword if seen == 1 else f"{keyword} x{seen}"
for keyword, seen in sorted(Counter(keywords).items())
)
def _normalise_schema(schema: Any, tool_name: str) -> Any:
"""Normalise one tool's schema and report, once, what that changed.
Both publication paths reach the model: the mirrored parameters Core
converts, and the tool-search catalog, which is returned verbatim and
passes no converter at all. Both call this, so a tool is normalised once
per turn and logged once per turn whichever path published it.
"""
rewrite = _Rewrite()
result = _to_inclusive_bounds(schema, rewrite)
if rewrite.dropped:
_LOGGER.warning(
"Dropped %s from %s's schema: the value is not a number, so it is "
"either the Draft-4 boolean flag or malformed, and Home "
"Assistant's codec answers such a node by refusing the tool or by "
"retyping the parameter. The server should advertise the bound as "
"a number, the only form draft 2020-12 permits.",
_tally(rewrite.dropped),
tool_name,
)
if rewrite.folded:
_LOGGER.debug(
"Rewrote %s in %s's schema to the inclusive twin so Home Assistant "
"does not re-emit it in the Draft-4 form. On a non-integer bound "
"the advertised edge is now one representable point wider than the "
"server enforces, so a model passing that edge gets a rejection "
"that is otherwise unattributable from any log.",
_tally(rewrite.folded),
tool_name,
)
return result
# Used when the server's initialize result carries no instructions (it always
# should — ha-mcp ships server-level instructions — but never render an empty
# prompt if a build does not).
@@ -632,10 +846,14 @@ class HaMcpLlmApi(llm.API):
return llm.APIInstance(self, prompt, llm_context, tools)
def _convert_parameters(self, tool: Any) -> vol.Schema | None:
"""Convert one tool's JSON schema, or None (logged) when it fails."""
def _convert_parameters(self, tool: Any, schema: Any) -> vol.Schema | None:
"""Convert one tool's normalised schema, or None (logged) when it fails.
The schema is normalised by the caller rather than here, so that a tool
the catalog also lists is rewritten and logged once rather than twice.
"""
try:
return convert_to_voluptuous(tool.inputSchema)
return convert_to_voluptuous(schema)
except Exception:
# One unconvertible schema must not take down the whole
# toolset for the conversation — skip that tool, loudly.
@@ -650,7 +868,8 @@ class HaMcpLlmApi(llm.API):
"""Mirror every exposed tool directly (full-catalog mode)."""
tools: list[llm.Tool] = []
for tool in exposed:
parameters = self._convert_parameters(tool)
schema = _normalise_schema(tool.inputSchema, tool.name)
parameters = self._convert_parameters(tool, schema)
if parameters is None:
continue
tools.append(
@@ -667,15 +886,26 @@ class HaMcpLlmApi(llm.API):
catalog: list[dict[str, Any]] = []
for tool in exposed:
exposed_names.add(tool.name)
# One normalisation feeds both surfaces, so a search result never
# shows a bound the mirrored tool does not advertise. Sharing the
# object rests on the converter not writing into its input. Both
# backends this component can reach were read for that: every
# write in voluptuous-openapi 0.4.1's convert_to_voluptuous and in
# probatio 0.11.4's JSON-Schema codec lands in a dict the
# converter built itself, and instrumenting probatio's inputs
# recorded no mutating call at all. A
# converter that wrote back would corrupt the catalog entry, so
# re-check this before pointing the component at a third one.
schema = _normalise_schema(tool.inputSchema, tool.name)
catalog.append(
{
"name": tool.name,
"description": tool.description or "",
"input_schema": tool.inputSchema,
"input_schema": schema,
}
)
if tool.name in pinned:
parameters = self._convert_parameters(tool)
parameters = self._convert_parameters(tool, schema)
if parameters is not None:
tools.append(
HaMcpTool(
@@ -756,7 +986,9 @@ async def async_register_llm_api(
exc_info=True,
)
return
# The embedded e2e (test_llm_api_registered_inside_ha) asserts on this
# The embedded e2e (test_llm_api_registered_inside_ha) and the in-HA
# probe tests (tests/src/e2e/workflows/embedded/test_llm_api_in_ha.py)
# assert on this
# message to prove the registration ran inside a real HA — keep the
# "Registered the HA-MCP toolset as LLM API" prefix stable.
_LOGGER.info(
+3 -2
View File
@@ -20,7 +20,8 @@
"iot_class": "local_push",
"issue_tracker": "https://github.com/homeassistant-ai/ha-mcp/issues",
"requirements": [
"ruamel.yaml>=0.18.0"
"ruamel.yaml>=0.18.0",
"voluptuous-openapi>=0.4.1"
],
"version": "2.1.2"
"version": "2.1.3"
}
+269 -45
View File
@@ -2,10 +2,10 @@
This module registers versioned ``ha_mcp_tools/*`` WebSocket commands that the
ha-mcp server calls in-process (same HA core, no REST/WS round-trips) behind a
capability gate. It registers twenty-three commands. It advertises twenty-six
capabilities: twenty-two command capabilities plus four additive flags
(dashboards_doc_search, search_visibility, search_entity_membership, and
search_visibility_allowlist_authorization);
capability gate. It registers twenty-three commands. It advertises twenty-seven
capabilities: twenty-two command capabilities plus five additive flags
(dashboards_doc_search, device_registry_child_semantics, search_visibility,
search_entity_membership, and search_visibility_allowlist_authorization);
the info handshake carries no capability entry:
* ``ha_mcp_tools/info`` — the handshake: ``schema_version`` + ``capabilities[]``
@@ -274,6 +274,7 @@ import logging
import re
from collections.abc import Collection, Mapping
from dataclasses import dataclass
from dataclasses import field as dataclass_field
from difflib import SequenceMatcher
from pathlib import Path
from typing import Any, NamedTuple
@@ -361,6 +362,12 @@ CAPABILITIES: list[str] = [
"blueprint_get",
"device_get",
"device_list",
# A semantic flag shared by every device-registry-backed read. Components
# predating this flag enumerate only Core's main ``devices`` collection and
# cannot provide authoritative Core 2026.9 child-device/effective-area data.
# A newer server therefore falls back to Core's native registry endpoints
# unless this flag accompanies the individual command capability.
"device_registry_child_semantics",
"entity_enrich",
"exposure",
"config_entries",
@@ -1015,6 +1022,19 @@ class _RegistryView:
label: Any = None
device: Any = None
# One request-local, conflict-filtered semantic snapshot plus the identities
# removed from it. Visibility filtering consumes the former and its warning
# projection consumes the latter, so both paths observe the same evidence.
_device_entries_by_id_cache: dict[str, Any] | None = dataclass_field(
default=None, init=False, repr=False, compare=False
)
_device_conflicting_ids_cache: frozenset[str] | None = dataclass_field(
default=None, init=False, repr=False, compare=False
)
_device_invalid_area_ids_cache: frozenset[str] | None = dataclass_field(
default=None, init=False, repr=False, compare=False
)
def _resolve_registries(hass: HomeAssistant) -> _RegistryView:
"""Snapshot the five registries. Test seam — monkeypatched in unit tests."""
@@ -1094,10 +1114,11 @@ def _do_search(
view = _resolve_registries(hass)
diagnostics: dict[str, int] = {}
partial_reasons: list[str] = []
# Visibility degradation warnings (unknown category / empty-registry allowlist /
# Assist unavailable), collected in the entity block below when a visibility
# filter is applied. Surfaced additively so the fast path isn't silent about
# incomplete filtering (parity with the server's load_hidden_set warnings).
# Visibility degradation warnings (unknown category / conflicting device /
# empty-registry allowlist / Assist unavailable), collected in the entity block
# below when a visibility filter is applied. Surfaced additively so the fast
# path isn't silent about incomplete filtering (parity with the server's
# load_hidden_set warnings).
visibility_warnings: list[str] = []
hidden: set[str] = set()
@@ -1516,19 +1537,22 @@ def _registry_enrichment(view: _RegistryView, entity_id: str) -> dict[str, Any]:
if reg
else []
)
area_id = getattr(reg, "area_id", None) if reg else None
area_id = _effective_area_for_entry(view, reg) if reg else None
device_id = getattr(reg, "device_id", None) if reg else None
labels = set(getattr(reg, "labels", None) or []) if reg else set()
hidden = bool(getattr(reg, "hidden_by", None)) if reg else False
dev = _device(view, device_id) if device_id else None
dev = (
_unambiguous_device_entries(view).get(device_id)
if isinstance(device_id, str) and device_id
else None
)
dev_texts: list[str] = []
if dev is not None:
if area_id is None:
area_id = getattr(dev, "area_id", None)
labels |= set(getattr(dev, "labels", None) or [])
dev_row = _device_dict_repr(dev) or {}
labels |= set(dev_row.get("labels") or [])
for attr in ("name_by_user", "name", "manufacturer", "model"):
val = getattr(dev, attr, None)
val = dev_row.get(attr)
if val:
dev_texts.append(str(val))
@@ -2325,6 +2349,170 @@ def _device(view: _RegistryView, device_id: str | None) -> Any:
return _call_lookup(view.device, "async_get", device_id)
def _device_collection_values(collection: Any, *, collection_name: str) -> list[Any]:
"""Enumerate a Core device collection across old and 2026.9 shapes.
Before Core 2026.9 ``registry.devices`` was a mapping-like container. Core
2026.9 exposes supported iterable collections for both ``devices`` and
``child_devices``. Mapping fakes and older containers still use ``values``;
modern collections are consumed by iteration so the deprecated mapping API
on ``registry.devices`` is not invoked.
"""
if collection is None:
return []
if isinstance(collection, Mapping):
try:
return list(collection.values())
except Exception: # pragma: no cover - defensive
_LOGGER.warning(
"failed to enumerate device registry collection %s",
collection_name,
exc_info=True,
)
return []
try:
return list(collection)
except Exception: # pragma: no cover - defensive
_LOGGER.warning(
"failed to enumerate device registry collection %s",
collection_name,
exc_info=True,
)
return []
def _unambiguous_device_entries(view: _RegistryView) -> dict[str, Any]:
"""Return one request-local map of unambiguous main and child devices.
Core 2026.9 stores child devices in a separate collection. Older releases
have only the mapping-like ``devices`` container. Duplicate ids cannot occur
in a valid Core registry; if a drifted/corrupt view supplies conflicting
entries, remove that identity rather than choosing one arbitrarily.
"""
if view._device_entries_by_id_cache is not None:
return view._device_entries_by_id_cache
reg = view.device
if reg is None:
view._device_entries_by_id_cache = {}
view._device_conflicting_ids_cache = frozenset()
view._device_invalid_area_ids_cache = frozenset()
return view._device_entries_by_id_cache
main_collection = getattr(reg, "devices", None)
if hasattr(reg, "child_devices"):
candidates = _device_collection_values(
main_collection, collection_name="devices"
)
candidates.extend(
_device_collection_values(
getattr(reg, "child_devices", None), collection_name="child_devices"
)
)
else:
# The pre-2026.9 container is mapping-like and iterates ids, not entries.
candidates = _mapping_values(main_collection)
by_id: dict[str, Any] = {}
conflicts: set[str] = set()
for entry in candidates:
device_id = getattr(entry, "id", None)
if not isinstance(device_id, str) or not device_id or device_id in conflicts:
continue
if device_id not in by_id:
by_id[device_id] = entry
continue
prior = _device_dict_repr(by_id[device_id])
current = _device_dict_repr(entry)
if prior != current or prior is None:
conflicts.add(device_id)
del by_id[device_id]
_LOGGER.warning(
"device registry contained conflicting device identity %r; "
"excluding it from this request",
device_id,
)
view._device_entries_by_id_cache = by_id
view._device_conflicting_ids_cache = frozenset(conflicts)
return view._device_entries_by_id_cache
def _all_device_entries(view: _RegistryView) -> list[Any]:
"""Return every unambiguous main and child device entry once."""
return list(_unambiguous_device_entries(view).values())
def _conflicting_device_ids(view: _RegistryView) -> frozenset[str]:
"""Return device identities excluded from this request as conflicting."""
_unambiguous_device_entries(view)
return view._device_conflicting_ids_cache or frozenset()
def _invalid_device_area_ids(view: _RegistryView) -> frozenset[str]:
"""Return device ids whose area evidence is malformed or has invalid ancestry."""
devices_by_id = _unambiguous_device_entries(view)
if view._device_invalid_area_ids_cache is not None:
return view._device_invalid_area_ids_cache
invalid: set[str] = set()
for device_id, device in devices_by_id.items():
row = _device_dict_repr(device)
if row is None:
invalid.add(device_id)
continue
direct_area = row.get("area_id")
if direct_area is not None:
if not isinstance(direct_area, str) or not direct_area:
invalid.add(device_id)
continue
parent_id = row.get("parent_device_id")
if parent_id is None:
continue
if not isinstance(parent_id, str) or not parent_id:
invalid.add(device_id)
continue
parent = devices_by_id.get(parent_id)
parent_row = _device_dict_repr(parent) if parent is not None else None
if parent_row is None or parent_row.get("parent_device_id") is not None:
invalid.add(device_id)
continue
parent_area = parent_row.get("area_id")
if parent_area is not None and (
not isinstance(parent_area, str) or not parent_area
):
invalid.add(device_id)
view._device_invalid_area_ids_cache = frozenset(invalid)
return view._device_invalid_area_ids_cache
def _effective_device_area_id(view: _RegistryView, device: Any) -> str | None:
"""Return Core 2026.9's direct-or-parent effective device area."""
devices_by_id = _unambiguous_device_entries(view)
device_row = _device_dict_repr(device)
if device_row is None:
return None
device_id = device_row.get("id")
if not isinstance(device_id, str) or not device_id:
return None
device = devices_by_id.get(device_id)
if device is None:
# Conflicting identities never contribute semantic placement.
return None
device_row = _device_dict_repr(device)
if device_row is None:
return None
direct_area = device_row.get("area_id")
if direct_area is not None:
return direct_area if isinstance(direct_area, str) and direct_area else None
parent_id = device_row.get("parent_device_id")
if not isinstance(parent_id, str) or not parent_id:
return None
parent = devices_by_id.get(parent_id)
parent_row = _device_dict_repr(parent) if parent is not None else None
if parent_row is None or parent_row.get("parent_device_id") is not None:
# Core requires a main-device parent. This also bounds malformed cycles.
return None
parent_area = parent_row.get("area_id")
return parent_area if isinstance(parent_area, str) and parent_area else None
def _area_name(view: _RegistryView, area_id: str | None) -> str | None:
if not area_id:
return None
@@ -2780,22 +2968,22 @@ def _overview_entity_registry(view: _RegistryView) -> list[dict[str, Any]]:
def _overview_device_registry(view: _RegistryView) -> list[dict[str, Any]]:
"""Device registry as a bare list (id + area + labels + name/manufacturer/model)."""
out: list[dict[str, Any]] = []
reg = view.device
devices = getattr(reg, "devices", None) if reg is not None else None
values = _mapping_values(devices)
for dev in values:
dev_id = getattr(dev, "id", None)
for dev in _all_device_entries(view):
dev_row = _device_dict_repr(dev)
if dev_row is None:
continue
dev_id = dev_row.get("id")
if not dev_id:
continue
out.append(
{
"id": dev_id,
"area_id": getattr(dev, "area_id", None),
"labels": sorted(str(x) for x in (getattr(dev, "labels", None) or [])),
"name": getattr(dev, "name", None),
"name_by_user": getattr(dev, "name_by_user", None),
"manufacturer": getattr(dev, "manufacturer", None),
"model": getattr(dev, "model", None),
"area_id": _effective_device_area_id(view, dev),
"labels": sorted(str(x) for x in (dev_row.get("labels") or [])),
"name": dev_row.get("name"),
"name_by_user": dev_row.get("name_by_user"),
"manufacturer": dev_row.get("manufacturer"),
"model": dev_row.get("model"),
}
)
return out
@@ -3138,9 +3326,10 @@ _BlueprintLoader.add_multi_constructor("!", _drop_blueprint_tag)
def _do_device_get(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
"""Return one device registry entry by id, optionally with its entities.
``{device: <DeviceEntry.dict_repr> | None}`` — ``registry.async_get(device_id)``
is a pure O(1) in-memory dict read, and the emitted body is core's
``DeviceEntry.dict_repr`` returned UNMODIFIED — exactly the shape
``{device: <DeviceEntry.dict_repr> | None}`` — a request-local snapshot over
Core's supported main and child collections rejects conflicting identities,
and the emitted body is core's ``DeviceEntry.dict_repr`` returned UNMODIFIED —
exactly the shape
``config/device_registry/list`` serializes (it sends
``json_bytes(entry.dict_repr)``), so a component-served record is byte-identical
to one legacy list element by construction (the WS transport JSON-encodes the
@@ -3157,15 +3346,25 @@ def _do_device_get(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any
to match what ``config/entity_registry/list`` returns (it lists disabled entities
too). The DeviceEntry dict itself stays exactly the raw shape — the join is a
sibling, so consumers keep their own transforms. The ``entities`` key is present
only when requested.
only when requested. A child-device result may also carry a sibling
``effective_area_id`` computed from its direct-or-parent placement; that
transport-only field is absent from the raw ``device`` mapping.
"""
device_id = params.get("device_id")
include_entities = params.get("include_entities", False)
view = _resolve_registries(hass)
entry = _device(view, device_id) if device_id else None
result: dict[str, Any] = {
"device": _device_dict_repr(entry) if entry is not None else None
}
entry = _unambiguous_device_entries(view).get(device_id) if device_id else None
entry_row = _device_dict_repr(entry) if entry is not None else None
result: dict[str, Any] = {"device": entry_row}
if (
entry is not None
and isinstance(entry_row, dict)
and isinstance(entry_row.get("parent_device_id"), str)
):
# Additive internal transport metadata. The raw child ``dict_repr`` stays
# byte-identical to Core while the server can expose its existing area_id
# field using Core's effective placement without a whole-registry read.
result["effective_area_id"] = _effective_device_area_id(view, entry)
if include_entities:
result["entities"] = _device_entities(view, device_id) if device_id else []
return result
@@ -3181,10 +3380,8 @@ def _do_device_list(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, An
than emitted as a partial record.
"""
view = _resolve_registries(hass)
reg = view.device
devices = getattr(reg, "devices", None) if reg is not None else None
out: list[dict[str, Any]] = []
for dev in _mapping_values(devices):
for dev in _all_device_entries(view):
repr_dict = _device_dict_repr(dev)
if repr_dict is not None:
out.append(repr_dict)
@@ -5028,6 +5225,16 @@ _ALLOWLIST_REGISTRY_EMPTY_WARNING = (
"this request (an allow_entity_ids list, if set, still applies) so the filter "
"does not blank every entity."
)
_DEVICE_REGISTRY_CONFLICT_WARNING = (
"Entity visibility filter is enabled with an area/label dimension but the "
"device registry contained conflicting identities; ambiguous device-derived "
"placement and labels were excluded."
)
_DEVICE_REGISTRY_INVALID_AREA_WARNING = (
"Entity visibility filter is enabled with an area dimension but the device "
"registry contained invalid area relationships; affected device-derived "
"placement was excluded."
)
class _AllowlistState(NamedTuple):
@@ -5206,8 +5413,10 @@ def _visibility_warnings(
Companion to :func:`_visibility_hidden_set`: the hidden-set function silently
fails open on a degraded dimension (an unknown ``exclude_category``, an
area/label allowlist against an empty registry, or a requested-but-unavailable
Assist dimension), so this returns the operator-facing warnings the server's
area/label dimension against conflicting device identities, an area dimension
against invalid device ancestry, an area/label allowlist against an empty
registry, or a requested-but-unavailable Assist dimension), so this returns
the operator-facing warnings the server's
``load_hidden_set`` would emit for the same config. The ha_search consumer
merges them into the response so the component fast path is no longer silent
about incomplete filtering. Byte-identical to ``visibility.resolver``'s warning
@@ -5225,6 +5434,19 @@ def _visibility_warnings(
if unknown:
warnings.append(_unknown_categories_warning(unknown))
area_or_label_dimension_active = bool(
visibility.get("exclude_areas")
or visibility.get("exclude_labels")
or visibility.get("allow_areas")
or visibility.get("allow_labels")
)
if area_or_label_dimension_active and _conflicting_device_ids(view):
warnings.append(_DEVICE_REGISTRY_CONFLICT_WARNING)
if (
visibility.get("exclude_areas") or visibility.get("allow_areas")
) and _invalid_device_area_ids(view):
warnings.append(_DEVICE_REGISTRY_INVALID_AREA_WARNING)
if inventory is None:
inventory = _visibility_inventory(view, states, visibility)
if inventory.allowlist.degraded:
@@ -5352,14 +5574,14 @@ def _entity_allowed(
def _effective_area_for_entry(view: _RegistryView, entry: Any) -> str | None:
"""An entity's ``area_id`` falling back to its device's (HA area inheritance)."""
"""Resolve entity direct area, then its device's direct-or-parent effective area."""
area_id = getattr(entry, "area_id", None)
if isinstance(area_id, str) and area_id:
return area_id
if area_id is not None:
return area_id if isinstance(area_id, str) and area_id else None
device_id = getattr(entry, "device_id", None)
if isinstance(device_id, str) and device_id:
dev = _device(view, device_id)
dev_area = getattr(dev, "area_id", None) if dev is not None else None
dev = _unambiguous_device_entries(view).get(device_id)
dev_area = _effective_device_area_id(view, dev) if dev is not None else None
return dev_area if isinstance(dev_area, str) and dev_area else None
return None
@@ -5369,9 +5591,11 @@ def _effective_labels_for_entry(view: _RegistryView, entry: Any) -> set[str]:
labels = set(getattr(entry, "labels", None) or [])
device_id = getattr(entry, "device_id", None)
if isinstance(device_id, str) and device_id:
dev = _device(view, device_id)
dev = _unambiguous_device_entries(view).get(device_id)
if dev is not None:
labels |= set(getattr(dev, "labels", None) or [])
dev_row = _device_dict_repr(dev)
if dev_row is not None:
labels |= set(dev_row.get("labels") or [])
return labels
+100 -50
View File
@@ -408,6 +408,27 @@ def _normalize_recommendation_item(item: dict) -> dict:
return _normalize_browse_item(item)
async def _get_recommendation_folder_items(
mass: Any,
provider: str,
item_id: str,
) -> list[Any]:
"""Fetch the concrete items of one recommendation folder.
``music.recommendations()`` only returns the folder metadata (name,
provider, item_id) — MA's API works in two steps, and the actual content
of a folder requires a second call to "music/recommendations/items"
(server-side: RecommendationsController.get_recommendation_items).
There is no Python-client wrapper for it yet, so we call it directly via
``send_command``.
"""
result = await asyncio.wait_for(
mass.send_command("music/recommendations/items", provider=provider, item_id=item_id),
timeout=10,
)
return list(result) if not isinstance(result, list) else result
async def _get_recommendations_via_ma_client(
hass: HomeAssistant,
) -> list[dict] | None:
@@ -439,59 +460,88 @@ async def _get_recommendations_via_ma_client(
result = await asyncio.wait_for(rec_fn(**kwargs), timeout=10)
folders = list(result) if not isinstance(result, list) else result
_LOGGER.debug("Recommendations: %d folders", len(folders))
normalized: list[dict] = []
for folder in folders:
f = _to_json_safe(folder)
folder_domain = str(f.get("provider_domain") or f.get("provider", "") or "")
folder_instance = str(
f.get("provider_instance_id_or_domain")
or f.get("provider_instance")
or f.get("provider_instance_id")
or folder_domain
safe_folders = [_to_json_safe(folder) for folder in folders]
# A folder from music.recommendations() only carries metadata —
# its actual content needs one extra call per folder. Fetch them
# all in parallel, isolating failures so one slow/broken tiroir
# doesn't blank the others.
fetch_tasks = [
_get_recommendation_folder_items(
mass, str(f.get("provider") or ""), str(f.get("item_id") or ""),
)
is_library_folder = folder_domain in ("library", "builtin", "")
items_raw = f.get("items") or []
items_norm = []
for i in items_raw:
i_safe = _to_json_safe(i)
if is_library_folder:
pm = i_safe.get("provider_mappings") or []
if pm:
i_safe["provider_mappings"] = [
m for m in pm
if (m.get("in_library") is True if isinstance(m, dict) else getattr(m, "in_library", True))
] or pm
item = _normalize_recommendation_item(i_safe)
if not is_library_folder:
if folder_instance and not item.get("provider_instances"):
item["provider_instances"] = [folder_instance]
if folder_domain and not item.get("providers"):
item["providers"] = [folder_domain]
items_norm.append(item)
for f in safe_folders
]
items_results = await asyncio.gather(*fetch_tasks, return_exceptions=True)
# Infer folder provider from items when MA doesn't provide it
inferred_domain = folder_domain
inferred_instance = folder_instance
if is_library_folder and items_norm:
instance_counts: dict[str, int] = {}
for itm in items_norm:
for pi in itm.get("provider_instances") or []:
if pi and pi not in ("builtin", "library"):
instance_counts[pi] = instance_counts.get(pi, 0) + 1
if instance_counts:
top_inst = max(instance_counts, key=instance_counts.get) # type: ignore[arg-type]
if instance_counts[top_inst] == len(items_norm):
inferred_instance = top_inst
inferred_domain = top_inst.split("--")[0] if "--" in top_inst else top_inst
normalized: list[dict] = []
for f, items_result in zip(safe_folders, items_results):
try:
if isinstance(items_result, BaseException):
_LOGGER.warning(
"Recommendations: items fetch failed for folder %r (provider=%s, item_id=%s): %s",
f.get("name"), f.get("provider"), f.get("item_id"), items_result,
)
items_raw = f.get("items") or []
else:
items_raw = items_result or f.get("items") or []
normalized.append({
"folder_id": f.get("item_id") or f.get("path") or "",
"name": f.get("name") or f.get("label") or "",
"icon": f.get("icon") or "",
"provider_domain": inferred_domain,
"provider_instance": inferred_instance,
"items": items_norm,
})
folder_domain = str(f.get("provider_domain") or f.get("provider", "") or "")
folder_instance = str(
f.get("provider_instance_id_or_domain")
or f.get("provider_instance")
or f.get("provider_instance_id")
or folder_domain
)
is_library_folder = folder_domain in ("library", "builtin", "")
items_norm = []
for i in items_raw:
i_safe = _to_json_safe(i)
if is_library_folder:
pm = i_safe.get("provider_mappings") or []
if pm:
i_safe["provider_mappings"] = [
m for m in pm
if (m.get("in_library") is True if isinstance(m, dict) else getattr(m, "in_library", True))
] or pm
item = _normalize_recommendation_item(i_safe)
if not is_library_folder:
if folder_instance and not item.get("provider_instances"):
item["provider_instances"] = [folder_instance]
if folder_domain and not item.get("providers"):
item["providers"] = [folder_domain]
items_norm.append(item)
# Infer folder provider from items when MA doesn't provide it
inferred_domain = folder_domain
inferred_instance = folder_instance
if is_library_folder and items_norm:
instance_counts: dict[str, int] = {}
for itm in items_norm:
for pi in itm.get("provider_instances") or []:
if pi and pi not in ("builtin", "library"):
instance_counts[pi] = instance_counts.get(pi, 0) + 1
if instance_counts:
top_inst = max(instance_counts, key=instance_counts.get) # type: ignore[arg-type]
if instance_counts[top_inst] == len(items_norm):
inferred_instance = top_inst
inferred_domain = top_inst.split("--")[0] if "--" in top_inst else top_inst
normalized.append({
"folder_id": f.get("item_id") or f.get("path") or "",
"name": f.get("name") or f.get("label") or "",
"icon": f.get("icon") or "",
"provider_domain": inferred_domain,
"provider_instance": inferred_instance,
"items": items_norm,
})
except Exception as folder_err: # noqa: BLE001
_LOGGER.warning(
"Recommendations: skipping folder %r due to processing error: %s",
f.get("name"), folder_err,
)
continue
return normalized
except asyncio.TimeoutError:
_LOGGER.warning("Recommendations fetch timed out")
@@ -9,5 +9,5 @@
"iot_class": "local_push",
"issue_tracker": "https://github.com/Patafoin/ha-my-music-library/issues",
"requirements": [],
"version": "3.12.4"
"version": "3.12.5"
}
@@ -5,7 +5,7 @@
* @version 1.0.0
*/
const CARD_VERSION = "3.12.4";
const CARD_VERSION = "3.12.5";
/* ─── Icons (inline SVG strings) ─────────────────────────── */
const ICONS = {
File diff suppressed because one or more lines are too long
Binary file not shown.
+13 -13
View File
@@ -38,7 +38,7 @@
"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
},
"linkquality": 120,
"linkquality": 112,
"power_on_behavior": "on"
},
"0xffffb40e0607aee8": {
@@ -57,7 +57,7 @@
"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
},
"linkquality": 94,
"linkquality": 87,
"power": 0.2,
"current": 0.01,
"power_on_behavior": "on"
@@ -67,7 +67,7 @@
"contact": true,
"tamper": false,
"battery_low": false,
"linkquality": 112,
"linkquality": 80,
"voltage": 3100
},
"0xffffb40e060762a4": {
@@ -88,7 +88,7 @@
"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
},
"linkquality": 102,
"linkquality": 123,
"power_on_behavior": "on"
},
"0xffffb40e0607b680": {
@@ -109,7 +109,7 @@
"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
},
"linkquality": 112,
"linkquality": 94,
"power_on_behavior": "on"
},
"0xffffb40e060895b3": {
@@ -120,7 +120,7 @@
"current": 0.01,
"power": 0.1,
"power_factor": 0.11,
"linkquality": 109,
"linkquality": 69,
"update": {
"state": "idle",
"installed_version": 268513381,
@@ -151,7 +151,7 @@
"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
},
"linkquality": 94,
"linkquality": 112,
"power_on_behavior": "on"
},
"0xb40e060fffe2d218": {
@@ -181,13 +181,13 @@
"latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/Sonoff/snzb02lwd_v1.1.0.ota",
"latest_release_notes": null
},
"linkquality": 109
"linkquality": 65
},
"0xffffb40e0606234b": {
"contact": true,
"battery_low": false,
"battery": 100,
"linkquality": 112,
"linkquality": 109,
"update": {
"state": "idle",
"installed_version": 36,
@@ -208,7 +208,7 @@
"brightness": 254,
"color_temp": 357,
"color_mode": "color_temp",
"linkquality": 109
"linkquality": 43
},
"0x7cc6b6fffe606710": {
"state": "ON",
@@ -220,14 +220,14 @@
"latest_release_notes": "https://ww8.ikea.com/ikeahomesmart/releasenotes/releasenotes.html"
},
"brightness": 254,
"linkquality": 47
"linkquality": 116
},
"0xb40e060fffe717c5": {
"battery": 100,
"occupancy": true,
"tamper": false,
"battery_low": false,
"linkquality": 80,
"linkquality": 87,
"voltage": 3100,
"update": {
"state": "idle",
@@ -242,7 +242,7 @@
"occupancy": false,
"tamper": false,
"battery_low": false,
"linkquality": 72,
"linkquality": 98,
"voltage": 3100,
"update": {
"state": "idle",