11 files
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "cc55565add58413bbcadba9f9cf8caba",
|
"id": "cc55565add58413bbcadba9f9cf8caba",
|
||||||
"url": "/hacsfiles/lovelace-mushroom/mushroom.js?hacstag=444350375522",
|
"url": "/hacsfiles/lovelace-mushroom/mushroom.js?hacstag=444350375523",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ DOMAIN = "ha_mcp_tools"
|
|||||||
# in CI. The
|
# in CI. The
|
||||||
# capability negotiation — not this version — gates each WS command (see
|
# capability negotiation — not this version — gates each WS command (see
|
||||||
# ``websocket_api.CAPABILITIES``).
|
# ``websocket_api.CAPABILITIES``).
|
||||||
COMPONENT_VERSION = "2.1.2"
|
COMPONENT_VERSION = "2.1.3"
|
||||||
|
|
||||||
# Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value
|
# Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value
|
||||||
# means "tools" so the pre-existing services entry keeps working across the
|
# 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).
|
# carrying the legacy default (a user-customized title is left alone).
|
||||||
TOOLS_ENTRY_TITLE = "HA-MCP File & YAML Tools"
|
TOOLS_ENTRY_TITLE = "HA-MCP File & YAML Tools"
|
||||||
TOOLS_ENTRY_LEGACY_TITLE = "HA MCP 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).
|
# Allowed directories for file operations (relative to config dir).
|
||||||
# "blueprints" is read-only BY DEFAULT — in ALLOWED_READ_DIRS but not
|
# "blueprints" is read-only BY DEFAULT — in ALLOWED_READ_DIRS but not
|
||||||
|
|||||||
@@ -47,11 +47,14 @@ stateless session each — the in-process server serves ``stateless_http=True``.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import copy
|
||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
|
from collections import Counter
|
||||||
from collections.abc import AsyncIterator, Callable, Iterable
|
from collections.abc import AsyncIterator, Callable, Iterable
|
||||||
from contextlib import AsyncExitStack, asynccontextmanager
|
from contextlib import AsyncExitStack, asynccontextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from functools import cache
|
from functools import cache
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
@@ -138,6 +141,17 @@ def _schema_converter() -> Callable[[Any], Any]:
|
|||||||
if err.name != "voluptuous_openapi":
|
if err.name != "voluptuous_openapi":
|
||||||
raise
|
raise
|
||||||
probatio = importlib.import_module("probatio")
|
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], probatio.from_openapi)
|
||||||
return cast(Callable[[Any], Any], legacy.convert_to_voluptuous)
|
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))
|
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
|
# Used when the server's initialize result carries no instructions (it always
|
||||||
# should — ha-mcp ships server-level instructions — but never render an empty
|
# should — ha-mcp ships server-level instructions — but never render an empty
|
||||||
# prompt if a build does not).
|
# prompt if a build does not).
|
||||||
@@ -632,10 +846,14 @@ class HaMcpLlmApi(llm.API):
|
|||||||
|
|
||||||
return llm.APIInstance(self, prompt, llm_context, tools)
|
return llm.APIInstance(self, prompt, llm_context, tools)
|
||||||
|
|
||||||
def _convert_parameters(self, tool: Any) -> vol.Schema | None:
|
def _convert_parameters(self, tool: Any, schema: Any) -> vol.Schema | None:
|
||||||
"""Convert one tool's JSON schema, or None (logged) when it fails."""
|
"""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:
|
try:
|
||||||
return convert_to_voluptuous(tool.inputSchema)
|
return convert_to_voluptuous(schema)
|
||||||
except Exception:
|
except Exception:
|
||||||
# One unconvertible schema must not take down the whole
|
# One unconvertible schema must not take down the whole
|
||||||
# toolset for the conversation — skip that tool, loudly.
|
# toolset for the conversation — skip that tool, loudly.
|
||||||
@@ -650,7 +868,8 @@ class HaMcpLlmApi(llm.API):
|
|||||||
"""Mirror every exposed tool directly (full-catalog mode)."""
|
"""Mirror every exposed tool directly (full-catalog mode)."""
|
||||||
tools: list[llm.Tool] = []
|
tools: list[llm.Tool] = []
|
||||||
for tool in exposed:
|
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:
|
if parameters is None:
|
||||||
continue
|
continue
|
||||||
tools.append(
|
tools.append(
|
||||||
@@ -667,15 +886,26 @@ class HaMcpLlmApi(llm.API):
|
|||||||
catalog: list[dict[str, Any]] = []
|
catalog: list[dict[str, Any]] = []
|
||||||
for tool in exposed:
|
for tool in exposed:
|
||||||
exposed_names.add(tool.name)
|
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(
|
catalog.append(
|
||||||
{
|
{
|
||||||
"name": tool.name,
|
"name": tool.name,
|
||||||
"description": tool.description or "",
|
"description": tool.description or "",
|
||||||
"input_schema": tool.inputSchema,
|
"input_schema": schema,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if tool.name in pinned:
|
if tool.name in pinned:
|
||||||
parameters = self._convert_parameters(tool)
|
parameters = self._convert_parameters(tool, schema)
|
||||||
if parameters is not None:
|
if parameters is not None:
|
||||||
tools.append(
|
tools.append(
|
||||||
HaMcpTool(
|
HaMcpTool(
|
||||||
@@ -756,7 +986,9 @@ async def async_register_llm_api(
|
|||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
return
|
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
|
# message to prove the registration ran inside a real HA — keep the
|
||||||
# "Registered the HA-MCP toolset as LLM API" prefix stable.
|
# "Registered the HA-MCP toolset as LLM API" prefix stable.
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"issue_tracker": "https://github.com/homeassistant-ai/ha-mcp/issues",
|
"issue_tracker": "https://github.com/homeassistant-ai/ha-mcp/issues",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"ruamel.yaml>=0.18.0"
|
"ruamel.yaml>=0.18.0",
|
||||||
|
"voluptuous-openapi>=0.4.1"
|
||||||
],
|
],
|
||||||
"version": "2.1.2"
|
"version": "2.1.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
This module registers versioned ``ha_mcp_tools/*`` WebSocket commands that the
|
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
|
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
|
capability gate. It registers twenty-three commands. It advertises twenty-seven
|
||||||
capabilities: twenty-two command capabilities plus four additive flags
|
capabilities: twenty-two command capabilities plus five additive flags
|
||||||
(dashboards_doc_search, search_visibility, search_entity_membership, and
|
(dashboards_doc_search, device_registry_child_semantics, search_visibility,
|
||||||
search_visibility_allowlist_authorization);
|
search_entity_membership, and search_visibility_allowlist_authorization);
|
||||||
the info handshake carries no capability entry:
|
the info handshake carries no capability entry:
|
||||||
|
|
||||||
* ``ha_mcp_tools/info`` — the handshake: ``schema_version`` + ``capabilities[]``
|
* ``ha_mcp_tools/info`` — the handshake: ``schema_version`` + ``capabilities[]``
|
||||||
@@ -274,6 +274,7 @@ import logging
|
|||||||
import re
|
import re
|
||||||
from collections.abc import Collection, Mapping
|
from collections.abc import Collection, Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from dataclasses import field as dataclass_field
|
||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, NamedTuple
|
from typing import Any, NamedTuple
|
||||||
@@ -361,6 +362,12 @@ CAPABILITIES: list[str] = [
|
|||||||
"blueprint_get",
|
"blueprint_get",
|
||||||
"device_get",
|
"device_get",
|
||||||
"device_list",
|
"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",
|
"entity_enrich",
|
||||||
"exposure",
|
"exposure",
|
||||||
"config_entries",
|
"config_entries",
|
||||||
@@ -1015,6 +1022,19 @@ class _RegistryView:
|
|||||||
label: Any = None
|
label: Any = None
|
||||||
device: 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:
|
def _resolve_registries(hass: HomeAssistant) -> _RegistryView:
|
||||||
"""Snapshot the five registries. Test seam — monkeypatched in unit tests."""
|
"""Snapshot the five registries. Test seam — monkeypatched in unit tests."""
|
||||||
@@ -1094,10 +1114,11 @@ def _do_search(
|
|||||||
view = _resolve_registries(hass)
|
view = _resolve_registries(hass)
|
||||||
diagnostics: dict[str, int] = {}
|
diagnostics: dict[str, int] = {}
|
||||||
partial_reasons: list[str] = []
|
partial_reasons: list[str] = []
|
||||||
# Visibility degradation warnings (unknown category / empty-registry allowlist /
|
# Visibility degradation warnings (unknown category / conflicting device /
|
||||||
# Assist unavailable), collected in the entity block below when a visibility
|
# empty-registry allowlist / Assist unavailable), collected in the entity block
|
||||||
# filter is applied. Surfaced additively so the fast path isn't silent about
|
# below when a visibility filter is applied. Surfaced additively so the fast
|
||||||
# incomplete filtering (parity with the server's load_hidden_set warnings).
|
# path isn't silent about incomplete filtering (parity with the server's
|
||||||
|
# load_hidden_set warnings).
|
||||||
visibility_warnings: list[str] = []
|
visibility_warnings: list[str] = []
|
||||||
hidden: set[str] = set()
|
hidden: set[str] = set()
|
||||||
|
|
||||||
@@ -1516,19 +1537,22 @@ def _registry_enrichment(view: _RegistryView, entity_id: str) -> dict[str, Any]:
|
|||||||
if reg
|
if reg
|
||||||
else []
|
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
|
device_id = getattr(reg, "device_id", None) if reg else None
|
||||||
labels = set(getattr(reg, "labels", None) or []) if reg else set()
|
labels = set(getattr(reg, "labels", None) or []) if reg else set()
|
||||||
hidden = bool(getattr(reg, "hidden_by", None)) if reg else False
|
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] = []
|
dev_texts: list[str] = []
|
||||||
if dev is not None:
|
if dev is not None:
|
||||||
if area_id is None:
|
dev_row = _device_dict_repr(dev) or {}
|
||||||
area_id = getattr(dev, "area_id", None)
|
labels |= set(dev_row.get("labels") or [])
|
||||||
labels |= set(getattr(dev, "labels", None) or [])
|
|
||||||
for attr in ("name_by_user", "name", "manufacturer", "model"):
|
for attr in ("name_by_user", "name", "manufacturer", "model"):
|
||||||
val = getattr(dev, attr, None)
|
val = dev_row.get(attr)
|
||||||
if val:
|
if val:
|
||||||
dev_texts.append(str(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)
|
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:
|
def _area_name(view: _RegistryView, area_id: str | None) -> str | None:
|
||||||
if not area_id:
|
if not area_id:
|
||||||
return None
|
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]]:
|
def _overview_device_registry(view: _RegistryView) -> list[dict[str, Any]]:
|
||||||
"""Device registry as a bare list (id + area + labels + name/manufacturer/model)."""
|
"""Device registry as a bare list (id + area + labels + name/manufacturer/model)."""
|
||||||
out: list[dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
reg = view.device
|
for dev in _all_device_entries(view):
|
||||||
devices = getattr(reg, "devices", None) if reg is not None else None
|
dev_row = _device_dict_repr(dev)
|
||||||
values = _mapping_values(devices)
|
if dev_row is None:
|
||||||
for dev in values:
|
continue
|
||||||
dev_id = getattr(dev, "id", None)
|
dev_id = dev_row.get("id")
|
||||||
if not dev_id:
|
if not dev_id:
|
||||||
continue
|
continue
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"id": dev_id,
|
"id": dev_id,
|
||||||
"area_id": getattr(dev, "area_id", None),
|
"area_id": _effective_device_area_id(view, dev),
|
||||||
"labels": sorted(str(x) for x in (getattr(dev, "labels", None) or [])),
|
"labels": sorted(str(x) for x in (dev_row.get("labels") or [])),
|
||||||
"name": getattr(dev, "name", None),
|
"name": dev_row.get("name"),
|
||||||
"name_by_user": getattr(dev, "name_by_user", None),
|
"name_by_user": dev_row.get("name_by_user"),
|
||||||
"manufacturer": getattr(dev, "manufacturer", None),
|
"manufacturer": dev_row.get("manufacturer"),
|
||||||
"model": getattr(dev, "model", None),
|
"model": dev_row.get("model"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return out
|
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]:
|
def _do_device_get(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Return one device registry entry by id, optionally with its entities.
|
"""Return one device registry entry by id, optionally with its entities.
|
||||||
|
|
||||||
``{device: <DeviceEntry.dict_repr> | None}`` — ``registry.async_get(device_id)``
|
``{device: <DeviceEntry.dict_repr> | None}`` — a request-local snapshot over
|
||||||
is a pure O(1) in-memory dict read, and the emitted body is core's
|
Core's supported main and child collections rejects conflicting identities,
|
||||||
``DeviceEntry.dict_repr`` returned UNMODIFIED — exactly the shape
|
and the emitted body is core's ``DeviceEntry.dict_repr`` returned UNMODIFIED —
|
||||||
|
exactly the shape
|
||||||
``config/device_registry/list`` serializes (it sends
|
``config/device_registry/list`` serializes (it sends
|
||||||
``json_bytes(entry.dict_repr)``), so a component-served record is byte-identical
|
``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
|
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
|
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
|
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
|
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")
|
device_id = params.get("device_id")
|
||||||
include_entities = params.get("include_entities", False)
|
include_entities = params.get("include_entities", False)
|
||||||
view = _resolve_registries(hass)
|
view = _resolve_registries(hass)
|
||||||
entry = _device(view, device_id) if device_id else None
|
entry = _unambiguous_device_entries(view).get(device_id) if device_id else None
|
||||||
result: dict[str, Any] = {
|
entry_row = _device_dict_repr(entry) if entry is not None else None
|
||||||
"device": _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:
|
if include_entities:
|
||||||
result["entities"] = _device_entities(view, device_id) if device_id else []
|
result["entities"] = _device_entities(view, device_id) if device_id else []
|
||||||
return result
|
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.
|
than emitted as a partial record.
|
||||||
"""
|
"""
|
||||||
view = _resolve_registries(hass)
|
view = _resolve_registries(hass)
|
||||||
reg = view.device
|
|
||||||
devices = getattr(reg, "devices", None) if reg is not None else None
|
|
||||||
out: list[dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
for dev in _mapping_values(devices):
|
for dev in _all_device_entries(view):
|
||||||
repr_dict = _device_dict_repr(dev)
|
repr_dict = _device_dict_repr(dev)
|
||||||
if repr_dict is not None:
|
if repr_dict is not None:
|
||||||
out.append(repr_dict)
|
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 "
|
"this request (an allow_entity_ids list, if set, still applies) so the filter "
|
||||||
"does not blank every entity."
|
"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):
|
class _AllowlistState(NamedTuple):
|
||||||
@@ -5206,8 +5413,10 @@ def _visibility_warnings(
|
|||||||
|
|
||||||
Companion to :func:`_visibility_hidden_set`: the hidden-set function silently
|
Companion to :func:`_visibility_hidden_set`: the hidden-set function silently
|
||||||
fails open on a degraded dimension (an unknown ``exclude_category``, an
|
fails open on a degraded dimension (an unknown ``exclude_category``, an
|
||||||
area/label allowlist against an empty registry, or a requested-but-unavailable
|
area/label dimension against conflicting device identities, an area dimension
|
||||||
Assist dimension), so this returns the operator-facing warnings the server's
|
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
|
``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
|
merges them into the response so the component fast path is no longer silent
|
||||||
about incomplete filtering. Byte-identical to ``visibility.resolver``'s warning
|
about incomplete filtering. Byte-identical to ``visibility.resolver``'s warning
|
||||||
@@ -5225,6 +5434,19 @@ def _visibility_warnings(
|
|||||||
if unknown:
|
if unknown:
|
||||||
warnings.append(_unknown_categories_warning(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:
|
if inventory is None:
|
||||||
inventory = _visibility_inventory(view, states, visibility)
|
inventory = _visibility_inventory(view, states, visibility)
|
||||||
if inventory.allowlist.degraded:
|
if inventory.allowlist.degraded:
|
||||||
@@ -5352,14 +5574,14 @@ def _entity_allowed(
|
|||||||
|
|
||||||
|
|
||||||
def _effective_area_for_entry(view: _RegistryView, entry: Any) -> str | None:
|
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)
|
area_id = getattr(entry, "area_id", None)
|
||||||
if isinstance(area_id, str) and area_id:
|
if area_id is not None:
|
||||||
return area_id
|
return area_id if isinstance(area_id, str) and area_id else None
|
||||||
device_id = getattr(entry, "device_id", None)
|
device_id = getattr(entry, "device_id", None)
|
||||||
if isinstance(device_id, str) and device_id:
|
if isinstance(device_id, str) and device_id:
|
||||||
dev = _device(view, device_id)
|
dev = _unambiguous_device_entries(view).get(device_id)
|
||||||
dev_area = getattr(dev, "area_id", None) if dev is not None else None
|
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 dev_area if isinstance(dev_area, str) and dev_area else None
|
||||||
return 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 [])
|
labels = set(getattr(entry, "labels", None) or [])
|
||||||
device_id = getattr(entry, "device_id", None)
|
device_id = getattr(entry, "device_id", None)
|
||||||
if isinstance(device_id, str) and device_id:
|
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:
|
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
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -408,6 +408,27 @@ def _normalize_recommendation_item(item: dict) -> dict:
|
|||||||
return _normalize_browse_item(item)
|
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(
|
async def _get_recommendations_via_ma_client(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
) -> list[dict] | None:
|
) -> list[dict] | None:
|
||||||
@@ -439,59 +460,88 @@ async def _get_recommendations_via_ma_client(
|
|||||||
result = await asyncio.wait_for(rec_fn(**kwargs), timeout=10)
|
result = await asyncio.wait_for(rec_fn(**kwargs), timeout=10)
|
||||||
folders = list(result) if not isinstance(result, list) else result
|
folders = list(result) if not isinstance(result, list) else result
|
||||||
_LOGGER.debug("Recommendations: %d folders", len(folders))
|
_LOGGER.debug("Recommendations: %d folders", len(folders))
|
||||||
normalized: list[dict] = []
|
|
||||||
for folder in folders:
|
safe_folders = [_to_json_safe(folder) for folder in folders]
|
||||||
f = _to_json_safe(folder)
|
|
||||||
folder_domain = str(f.get("provider_domain") or f.get("provider", "") or "")
|
# A folder from music.recommendations() only carries metadata —
|
||||||
folder_instance = str(
|
# its actual content needs one extra call per folder. Fetch them
|
||||||
f.get("provider_instance_id_or_domain")
|
# all in parallel, isolating failures so one slow/broken tiroir
|
||||||
or f.get("provider_instance")
|
# doesn't blank the others.
|
||||||
or f.get("provider_instance_id")
|
fetch_tasks = [
|
||||||
or folder_domain
|
_get_recommendation_folder_items(
|
||||||
|
mass, str(f.get("provider") or ""), str(f.get("item_id") or ""),
|
||||||
)
|
)
|
||||||
is_library_folder = folder_domain in ("library", "builtin", "")
|
for f in safe_folders
|
||||||
items_raw = f.get("items") or []
|
]
|
||||||
items_norm = []
|
items_results = await asyncio.gather(*fetch_tasks, return_exceptions=True)
|
||||||
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
|
normalized: list[dict] = []
|
||||||
inferred_domain = folder_domain
|
for f, items_result in zip(safe_folders, items_results):
|
||||||
inferred_instance = folder_instance
|
try:
|
||||||
if is_library_folder and items_norm:
|
if isinstance(items_result, BaseException):
|
||||||
instance_counts: dict[str, int] = {}
|
_LOGGER.warning(
|
||||||
for itm in items_norm:
|
"Recommendations: items fetch failed for folder %r (provider=%s, item_id=%s): %s",
|
||||||
for pi in itm.get("provider_instances") or []:
|
f.get("name"), f.get("provider"), f.get("item_id"), items_result,
|
||||||
if pi and pi not in ("builtin", "library"):
|
)
|
||||||
instance_counts[pi] = instance_counts.get(pi, 0) + 1
|
items_raw = f.get("items") or []
|
||||||
if instance_counts:
|
else:
|
||||||
top_inst = max(instance_counts, key=instance_counts.get) # type: ignore[arg-type]
|
items_raw = items_result or f.get("items") or []
|
||||||
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_domain = str(f.get("provider_domain") or f.get("provider", "") or "")
|
||||||
"folder_id": f.get("item_id") or f.get("path") or "",
|
folder_instance = str(
|
||||||
"name": f.get("name") or f.get("label") or "",
|
f.get("provider_instance_id_or_domain")
|
||||||
"icon": f.get("icon") or "",
|
or f.get("provider_instance")
|
||||||
"provider_domain": inferred_domain,
|
or f.get("provider_instance_id")
|
||||||
"provider_instance": inferred_instance,
|
or folder_domain
|
||||||
"items": items_norm,
|
)
|
||||||
})
|
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
|
return normalized
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
_LOGGER.warning("Recommendations fetch timed out")
|
_LOGGER.warning("Recommendations fetch timed out")
|
||||||
|
|||||||
@@ -9,5 +9,5 @@
|
|||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"issue_tracker": "https://github.com/Patafoin/ha-my-music-library/issues",
|
"issue_tracker": "https://github.com/Patafoin/ha-my-music-library/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"version": "3.12.4"
|
"version": "3.12.5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* @version 1.0.0
|
* @version 1.0.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CARD_VERSION = "3.12.4";
|
const CARD_VERSION = "3.12.5";
|
||||||
|
|
||||||
/* ─── Icons (inline SVG strings) ─────────────────────────── */
|
/* ─── Icons (inline SVG strings) ─────────────────────────── */
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
+13
-13
@@ -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_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
|
||||||
"latest_release_notes": null
|
"latest_release_notes": null
|
||||||
},
|
},
|
||||||
"linkquality": 120,
|
"linkquality": 112,
|
||||||
"power_on_behavior": "on"
|
"power_on_behavior": "on"
|
||||||
},
|
},
|
||||||
"0xffffb40e0607aee8": {
|
"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_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
|
||||||
"latest_release_notes": null
|
"latest_release_notes": null
|
||||||
},
|
},
|
||||||
"linkquality": 94,
|
"linkquality": 87,
|
||||||
"power": 0.2,
|
"power": 0.2,
|
||||||
"current": 0.01,
|
"current": 0.01,
|
||||||
"power_on_behavior": "on"
|
"power_on_behavior": "on"
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
"contact": true,
|
"contact": true,
|
||||||
"tamper": false,
|
"tamper": false,
|
||||||
"battery_low": false,
|
"battery_low": false,
|
||||||
"linkquality": 112,
|
"linkquality": 80,
|
||||||
"voltage": 3100
|
"voltage": 3100
|
||||||
},
|
},
|
||||||
"0xffffb40e060762a4": {
|
"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_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
|
||||||
"latest_release_notes": null
|
"latest_release_notes": null
|
||||||
},
|
},
|
||||||
"linkquality": 102,
|
"linkquality": 123,
|
||||||
"power_on_behavior": "on"
|
"power_on_behavior": "on"
|
||||||
},
|
},
|
||||||
"0xffffb40e0607b680": {
|
"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_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
|
||||||
"latest_release_notes": null
|
"latest_release_notes": null
|
||||||
},
|
},
|
||||||
"linkquality": 112,
|
"linkquality": 94,
|
||||||
"power_on_behavior": "on"
|
"power_on_behavior": "on"
|
||||||
},
|
},
|
||||||
"0xffffb40e060895b3": {
|
"0xffffb40e060895b3": {
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
"current": 0.01,
|
"current": 0.01,
|
||||||
"power": 0.1,
|
"power": 0.1,
|
||||||
"power_factor": 0.11,
|
"power_factor": 0.11,
|
||||||
"linkquality": 109,
|
"linkquality": 69,
|
||||||
"update": {
|
"update": {
|
||||||
"state": "idle",
|
"state": "idle",
|
||||||
"installed_version": 268513381,
|
"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_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
|
||||||
"latest_release_notes": null
|
"latest_release_notes": null
|
||||||
},
|
},
|
||||||
"linkquality": 94,
|
"linkquality": 112,
|
||||||
"power_on_behavior": "on"
|
"power_on_behavior": "on"
|
||||||
},
|
},
|
||||||
"0xb40e060fffe2d218": {
|
"0xb40e060fffe2d218": {
|
||||||
@@ -181,13 +181,13 @@
|
|||||||
"latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/Sonoff/snzb02lwd_v1.1.0.ota",
|
"latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/Sonoff/snzb02lwd_v1.1.0.ota",
|
||||||
"latest_release_notes": null
|
"latest_release_notes": null
|
||||||
},
|
},
|
||||||
"linkquality": 109
|
"linkquality": 65
|
||||||
},
|
},
|
||||||
"0xffffb40e0606234b": {
|
"0xffffb40e0606234b": {
|
||||||
"contact": true,
|
"contact": true,
|
||||||
"battery_low": false,
|
"battery_low": false,
|
||||||
"battery": 100,
|
"battery": 100,
|
||||||
"linkquality": 112,
|
"linkquality": 109,
|
||||||
"update": {
|
"update": {
|
||||||
"state": "idle",
|
"state": "idle",
|
||||||
"installed_version": 36,
|
"installed_version": 36,
|
||||||
@@ -208,7 +208,7 @@
|
|||||||
"brightness": 254,
|
"brightness": 254,
|
||||||
"color_temp": 357,
|
"color_temp": 357,
|
||||||
"color_mode": "color_temp",
|
"color_mode": "color_temp",
|
||||||
"linkquality": 109
|
"linkquality": 43
|
||||||
},
|
},
|
||||||
"0x7cc6b6fffe606710": {
|
"0x7cc6b6fffe606710": {
|
||||||
"state": "ON",
|
"state": "ON",
|
||||||
@@ -220,14 +220,14 @@
|
|||||||
"latest_release_notes": "https://ww8.ikea.com/ikeahomesmart/releasenotes/releasenotes.html"
|
"latest_release_notes": "https://ww8.ikea.com/ikeahomesmart/releasenotes/releasenotes.html"
|
||||||
},
|
},
|
||||||
"brightness": 254,
|
"brightness": 254,
|
||||||
"linkquality": 47
|
"linkquality": 116
|
||||||
},
|
},
|
||||||
"0xb40e060fffe717c5": {
|
"0xb40e060fffe717c5": {
|
||||||
"battery": 100,
|
"battery": 100,
|
||||||
"occupancy": true,
|
"occupancy": true,
|
||||||
"tamper": false,
|
"tamper": false,
|
||||||
"battery_low": false,
|
"battery_low": false,
|
||||||
"linkquality": 80,
|
"linkquality": 87,
|
||||||
"voltage": 3100,
|
"voltage": 3100,
|
||||||
"update": {
|
"update": {
|
||||||
"state": "idle",
|
"state": "idle",
|
||||||
@@ -242,7 +242,7 @@
|
|||||||
"occupancy": false,
|
"occupancy": false,
|
||||||
"tamper": false,
|
"tamper": false,
|
||||||
"battery_low": false,
|
"battery_low": false,
|
||||||
"linkquality": 72,
|
"linkquality": 98,
|
||||||
"voltage": 3100,
|
"voltage": 3100,
|
||||||
"update": {
|
"update": {
|
||||||
"state": "idle",
|
"state": "idle",
|
||||||
|
|||||||
Reference in New Issue
Block a user