11 files
This commit is contained in:
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user