This commit is contained in:
Home Assistant Version Control committed 2026-09-04 13:37:39 +00:00
1 parent 70db0a842d
commit 1f0e4d0959
6 files changed
+234 -124

No files matched your search

+6 -5
View File
@@ -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.1" COMPONENT_VERSION = "2.1.2"
# 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
@@ -471,10 +471,11 @@ DATA_LLM_API_UNSUB = "llm_api_unsub"
WEBHOOK_AUTH_NONE = "none" # secret webhook URL is the shared secret (default) WEBHOOK_AUTH_NONE = "none" # secret webhook URL is the shared secret (default)
WEBHOOK_AUTH_HA = "ha_auth" # HA-native bearer (HA core is the OAuth AS) WEBHOOK_AUTH_HA = "ha_auth" # HA-native bearer (HA core is the OAuth AS)
# Self-hosted OAuth 2.1 authorization server with a static client_id/secret, # Self-hosted OAuth 2.1 authorization server with a static client_id/secret,
# ported from the webhook-proxy add-on's "legacy" mode. Needed because HA # ported from the webhook-proxy add-on's "legacy" mode. Originally needed
# core's /auth/authorize does not yet fetch Client ID Metadata Documents for # because HA core's /auth/authorize did not fetch Client ID Metadata Documents
# cross-origin redirect_uris (home-assistant/core#176282), which is what # for cross-origin redirect_uris before 2026.9 (home-assistant/core#176282,
# Google Gemini Spark's custom connected apps require. # fixed by #176286), which Google Gemini Spark's custom connected apps require;
# kept as the pasted-credential fallback for clients that want one.
WEBHOOK_AUTH_LEGACY = "legacy" WEBHOOK_AUTH_LEGACY = "legacy"
# Default bind host + port. 9584 (not the add-on's 9583) so this in-process # Default bind host + port. 9584 (not the add-on's 9583) so this in-process
+1 -1
View File
@@ -22,5 +22,5 @@
"requirements": [ "requirements": [
"ruamel.yaml>=0.18.0" "ruamel.yaml>=0.18.0"
], ],
"version": "2.1.1" "version": "2.1.2"
} }
@@ -4,10 +4,11 @@ Ported from the webhook-proxy add-on's ``mcp_proxy/oauth.py`` ``OAuthProvider``
+ ``AuthorizeView`` + ``TokenView`` (the proven ``legacy`` mode). Self-hosted, + ``AuthorizeView`` + ``TokenView`` (the proven ``legacy`` mode). Self-hosted,
single-tenant authorization server with a static client_id/client_secret pair, single-tenant authorization server with a static client_id/client_secret pair,
for MCP clients that need a credential to paste rather than HA core's native for MCP clients that need a credential to paste rather than HA core's native
OAuth (``ha_auth``) — currently just Google Gemini Spark, whose custom OAuth (``ha_auth``). Written for Google Gemini Spark, whose custom connected
connected apps use the Client ID Metadata Document pattern that HA core's apps use the Client ID Metadata Document pattern that HA core's
``/auth/authorize`` does not yet support for cross-origin redirect_uris ``/auth/authorize`` did not support for cross-origin redirect_uris before
(home-assistant/core#176282). 2026.9 (home-assistant/core#176282, fixed by #176286); ``ha_auth`` validates
CIMD itself since 2.0.0, so this is now the pasted-credential fallback.
Unlike the add-on, this module holds no reference to ``hass``, the webhook id, Unlike the add-on, this module holds no reference to ``hass``, the webhook id,
or a public base URL: the discovery-document layer (RFC 8414 / RFC 9728) and or a public base URL: the discovery-document layer (RFC 8414 / RFC 9728) and
+203 -95
View File
@@ -2,9 +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-five capability gate. It registers twenty-three commands. It advertises twenty-six
capabilities: twenty-two command capabilities plus three additive flags capabilities: twenty-two command capabilities plus four additive flags
(dashboards_doc_search, search_visibility, and search_entity_membership); (dashboards_doc_search, search_visibility, 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[]``
@@ -111,10 +112,17 @@ the info handshake carries no capability entry:
:func:`_visibility_hidden_set` mirrors the server's ``hidden_entity_ids``, with :func:`_visibility_hidden_set` mirrors the server's ``hidden_entity_ids``, with
the Assist dimension delegated to core's ``async_should_expose``), so the Assist dimension delegated to core's ``async_should_expose``), so
``ha_search`` can route through the component even with an active filter. A ``ha_search`` can route through the component even with an active filter. A
degraded dimension (unknown ``exclude_category`` / empty-registry allowlist / second ``search_visibility_allowlist_authorization`` capability declares that
unavailable Assist) fails open, and :func:`_visibility_warnings` returns the this component understands the ``allowlist_authorization`` wire key, which the
resolver-parity ``visibility_warnings`` the response carries so the filtering is server sends only to a component advertising it and which selects the revised
not silently incomplete. precedence (an allowlist match authorizes past category, HA-hidden, and Assist
filters). Without the key the component keeps the legacy conjunctive
precedence, so it stays correct against an older released server that still
resolves the old way in its own outbound scan. A degraded dimension (unknown
``exclude_category`` / empty-registry allowlist / unavailable Assist) fails
open, and :func:`_visibility_warnings` returns the resolver-parity
``visibility_warnings`` carried by the response so filtering is not silently
incomplete.
* ``ha_mcp_tools/server_entry`` — the component locates its OWN server config * ``ha_mcp_tools/server_entry`` — the component locates its OWN server config
entry (``entry.data[CONF_ENTRY_TYPE] == server``, the one marker key it reads entry (``entry.data[CONF_ENTRY_TYPE] == server``, the one marker key it reads
from ``entry.data``) and returns ``{entry_id, channel, pip_spec}`` (channel / from ``entry.data``) and returns ``{entry_id, channel, pip_spec}`` (channel /
@@ -268,7 +276,7 @@ from collections.abc import Collection, Mapping
from dataclasses import dataclass from dataclasses import dataclass
from difflib import SequenceMatcher from difflib import SequenceMatcher
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, NamedTuple
import voluptuous as vol import voluptuous as vol
import yaml # type: ignore[import-untyped] import yaml # type: ignore[import-untyped]
@@ -376,6 +384,11 @@ CAPABILITIES: list[str] = [
# would ignore the param is never sent it (param-sniffing is banned for # would ignore the param is never sent it (param-sniffing is banned for
# routing; the CAPABILITIES flag is what the server gates on). # routing; the CAPABILITIES flag is what the server gates on).
"search_visibility", "search_visibility",
# A semantic flag on search_visibility: this component understands the
# ``allowlist_authorization`` wire key (revised allowlist precedence). The
# server sends that key only to a component advertising this flag, and with
# an active allowlist requires the flag or falls back to its legacy resolver.
"search_visibility_allowlist_authorization",
"server_entry", "server_entry",
# The WRITE counterpart of ``server_entry`` (Phase 3). The server gates its # The WRITE counterpart of ``server_entry`` (Phase 3). The server gates its
# ``ha_dev_manage_server(update_source)`` embedded-mode direct-write on this; an # ``ha_dev_manage_server(update_source)`` embedded-mode direct-write on this; an
@@ -644,10 +657,13 @@ def _info_schema() -> dict[Any, Any]:
# The nine hide dimensions ``VisibilityConfig.to_wire`` emits, split by wire type # The nine hide dimensions ``VisibilityConfig.to_wire`` emits, split by wire type
# (seven id/name lists, two bool flags). Kept in lockstep with the server's # (seven id/name lists, two bool flags), plus the optional
# ``to_wire`` and :func:`_visibility_hidden_set`; a new dimension is a new component # ``allowlist_authorization`` precedence flag the server adds for a component that
# capability, added on BOTH sides (see :func:`_visibility_param_schema`). The union # advertises ``search_visibility_allowlist_authorization``. Kept in lockstep with
# is pinned equal to the server resolver's key set by the cross-seam contract test. # the server's ``to_wire`` / ``VisibilityWire`` and :func:`_visibility_hidden_set`;
# a new dimension is a new component capability, added on BOTH sides (see
# :func:`_visibility_param_schema`). The union is pinned equal to the server
# resolver's key set by the cross-seam contract test.
_VISIBILITY_LIST_KEYS = ( _VISIBILITY_LIST_KEYS = (
"exclude_categories", "exclude_categories",
"deny_entity_ids", "deny_entity_ids",
@@ -657,18 +673,25 @@ _VISIBILITY_LIST_KEYS = (
"allow_areas", "allow_areas",
"allow_labels", "allow_labels",
) )
_VISIBILITY_BOOL_KEYS = ("exclude_hidden", "respect_assist_exposure") _VISIBILITY_BOOL_KEYS = (
"exclude_hidden",
"respect_assist_exposure",
"allowlist_authorization",
)
def _visibility_param_schema() -> Any: def _visibility_param_schema() -> Any:
"""Voluptuous schema for the ``search`` ``visibility`` dict — exactly the nine keys. """Voluptuous schema for the ``search`` ``visibility`` dict — exactly the ten keys.
Enumerating the known dimensions (PREVENT_EXTRA is voluptuous' default for a The nine hide dimensions plus the ``allowlist_authorization`` precedence flag
nested ``Schema``) makes an unknown key a loud ``invalid_format`` command error (sent only when this component advertises
rather than a silent drop. If a newer server emits a tenth dimension to this ``search_visibility_allowlist_authorization``; absent means legacy
1.2.0 component, the server's error taxonomy converts that into a legacy fallback precedence). Enumerating the known keys (PREVENT_EXTRA is voluptuous' default
with the filter STILL correctly applied — structural fail-closed for free — for a nested ``Schema``) makes an unknown key a loud ``invalid_format`` command
instead of partial, unwarned filtering. Built at call time so it honors the error rather than a silent drop. If a newer server emits an eleventh key to
this component, the server's error taxonomy converts that into a legacy
fallback with the filter STILL correctly applied — structural fail-closed for
free — instead of partial, unwarned filtering. Built at call time so it honors the
monkeypatched ``vol`` in the unit suite. monkeypatched ``vol`` in the unit suite.
""" """
schema: dict[Any, Any] = {vol.Optional(key): [str] for key in _VISIBILITY_LIST_KEYS} schema: dict[Any, Any] = {vol.Optional(key): [str] for key in _VISIBILITY_LIST_KEYS}
@@ -693,15 +716,13 @@ def _search_schema() -> dict[Any, Any]:
int, vol.Range(min=1, max=MAX_RESULTS) int, vol.Range(min=1, max=MAX_RESULTS)
), ),
vol.Optional("offset", default=0): vol.All(int, vol.Range(min=0)), vol.Optional("offset", default=0): vol.All(int, vol.Range(min=0)),
# Opt-in entity-visibility filter (``search_visibility`` capability): the # Opt-in entity visibility for component search. The component advertises
# server's raw VisibilityConfig hide dimensions. When present and # ``search_visibility`` and ``search_visibility_allowlist_authorization``;
# non-empty, hidden entities are excluded from the entity results before # the server reads those flags before sending these raw VisibilityConfig
# counts/pagination, mirroring the legacy ``load_hidden_set`` hard-exclude # dimensions, and adds ``allowlist_authorization`` only for the second flag.
# so ``ha_search`` can drop its "filter active -> legacy only" gate. Gated # The ten known keys are enumerated so an unknown one fails loudly and the
# by the CAPABILITIES flag, so an old component never receives it. The nine # server falls back to its own filtered legacy path.
# known keys are enumerated so an unknown dimension fails loudly (the server # See ``_visibility_param_schema``.
# then falls back to legacy with the filter applied) — see
# :func:`_visibility_param_schema`.
vol.Optional("visibility"): _visibility_param_schema(), vol.Optional("visibility"): _visibility_param_schema(),
} }
@@ -1110,23 +1131,30 @@ def _do_search(
# :func:`_visibility_hidden_set`. # :func:`_visibility_hidden_set`.
if isinstance(visibility, Mapping) and visibility: if isinstance(visibility, Mapping) and visibility:
states_list = _iter_states(hass) states_list = _iter_states(hass)
# Probe Assist availability once (only when the config asks for it) so a inventory = _visibility_inventory(view, states_list, visibility)
# requested-but-unavailable Assist dimension both skips its hiding and assist_applies = (
# surfaces the resolver-parity degradation warning. bool(visibility.get("respect_assist_exposure"))
assist_available = ( and not inventory.allowlist.authorized
_assist_exposure_available(hass)
if visibility.get("respect_assist_exposure")
else True
) )
# Short-circuit the probe unless Assist applies. True on the skipped
# path means there is no Assist degradation to report; the hidden-set
# helper does not consult Assist while an authorizing allowlist is
# active (a legacy wire keeps Assist in play, so the probe still runs).
assist_available = not assist_applies or _assist_exposure_available(hass)
hidden = _visibility_hidden_set( hidden = _visibility_hidden_set(
view, view,
states_list, states_list,
visibility, visibility,
lambda eid: _assist_should_expose(hass, eid), lambda eid: _assist_should_expose(hass, eid),
assist_available=assist_available, assist_available=assist_available,
inventory=inventory,
) )
visibility_warnings = _visibility_warnings( visibility_warnings = _visibility_warnings(
view, states_list, visibility, assist_available=assist_available view,
states_list,
visibility,
assist_available=assist_available,
inventory=inventory,
) )
if hidden: if hidden:
scored_entities = [ scored_entities = [
@@ -2614,6 +2642,7 @@ def _all_entity_entries(view: _RegistryView) -> list[Any]:
try: try:
return list(entities.values()) return list(entities.values())
except Exception: # pragma: no cover - defensive except Exception: # pragma: no cover - defensive
_LOGGER.warning("entity registry enumeration failed", exc_info=True)
return [] return []
@@ -5001,6 +5030,67 @@ _ALLOWLIST_REGISTRY_EMPTY_WARNING = (
) )
class _AllowlistState(NamedTuple):
"""Effective restrict-mode activity, registry degradation, and precedence.
``authorized`` is ``active`` plus the ``allowlist_authorization`` wire flag:
an allow match then authorizes the entity past the category, HA-hidden, and
Assist filters. Without the flag the legacy conjunctive semantics apply even
while ``active`` is True (an older server resolves that way itself).
"""
active: bool
degraded: bool
authorized: bool
class _VisibilityInventory(NamedTuple):
"""Registry/state indexes and allowlist state shared by one search."""
registry_by_id: dict[str, Any]
state_ids: set[str]
allowlist: _AllowlistState
def _visibility_allowlist_state(
registry_by_id: Mapping[str, Any],
state_ids: set[str],
visibility: Mapping[str, Any],
) -> _AllowlistState:
"""Resolve allowlist activity from one precomputed search inventory.
``degraded`` deliberately remains true when an explicit ``allow_entity_ids``
list keeps restrict mode active: only the registry-derived allow dimensions
degraded, so callers still warn while the explicit ID allowlist remains in
force. This differs from the resolver's pre-fetch predicate, which asks only
whether degradation would make Assist relevant again.
``authorized`` additionally requires the ``allowlist_authorization`` wire flag:
an active allowlist alone keeps the legacy conjunctive precedence.
"""
entity_ids_active = bool(visibility.get("allow_entity_ids"))
registry_dimensions_active = bool(
visibility.get("allow_areas") or visibility.get("allow_labels")
)
degraded = registry_dimensions_active and not registry_by_id and bool(state_ids)
active = entity_ids_active or (registry_dimensions_active and not degraded)
authorized = active and bool(visibility.get("allowlist_authorization"))
return _AllowlistState(active, degraded, authorized)
def _visibility_inventory(
view: _RegistryView, states: Any, visibility: Mapping[str, Any]
) -> _VisibilityInventory:
"""Build the registry/state inventory once for one visibility computation."""
registry_by_id = _registry_index_by_id(view)
state_ids = _state_entity_ids(states)
return _VisibilityInventory(
registry_by_id,
state_ids,
_visibility_allowlist_state(registry_by_id, state_ids, visibility),
)
def _unknown_categories_warning(unknown_categories: set[str]) -> str: def _unknown_categories_warning(unknown_categories: set[str]) -> str:
"""The resolver's unknown-``exclude_categories`` warning text (byte-identical).""" """The resolver's unknown-``exclude_categories`` warning text (byte-identical)."""
return ( return (
@@ -5016,14 +5106,17 @@ def _visibility_hidden_set(
should_expose_fn: Any, should_expose_fn: Any,
*, *,
assist_available: bool = True, assist_available: bool = True,
inventory: _VisibilityInventory | None = None,
) -> set[str]: ) -> set[str]:
"""Compute the opt-in hidden entity_id set, mirroring the server's resolver. """Compute the opt-in hidden entity_id set, mirroring the server's resolver.
A pure replication of ``visibility.resolver.hidden_entity_ids`` over the live A pure replication of ``visibility.resolver.hidden_entity_ids`` over the live
``_RegistryView`` + ``states`` (rather than the WS ``{success, result}`` ``_RegistryView`` + ``states`` (rather than the WS ``{success, result}``
payloads the server passes): a conjunction of independent hide dimensions — payloads the server passes), including its precedence: concrete deny and
the deny list, the category / hidden / area / label excludes (area/labels are area/label excludes win; an allowlist match authorizes past the broad category,
device-inherited), the allow-list restrict mode, and the Assist dimension. The Home Assistant hidden-state, and Assist filters when the wire opted into that
revised precedence with ``allowlist_authorization`` (without the flag those
filters still apply to a matched entity, matching the older server). The
Assist dimension delegates to the injectable ``should_expose_fn(entity_id) -> Assist dimension delegates to the injectable ``should_expose_fn(entity_id) ->
bool`` (:func:`_assist_should_expose` in production — a READ-ONLY reconstruction bool`` (:func:`_assist_should_expose` in production — a READ-ONLY reconstruction
of core's ``async_should_expose`` from the explicit exposure map + expose_new + of core's ``async_should_expose`` from the explicit exposure map + expose_new +
@@ -5033,11 +5126,11 @@ def _visibility_hidden_set(
computed defaults back, which this fast path must not do (see computed defaults back, which this fast path must not do (see
:func:`_assist_should_expose`). :func:`_assist_should_expose`).
``should_expose_fn`` is consulted only when ``respect_assist_exposure`` is set ``should_expose_fn`` is consulted only when ``respect_assist_exposure`` is set,
AND ``assist_available`` is True. When the config requests the Assist dimension no AUTHORIZING allowlist is active, and ``assist_available`` is True. When the config
but the exposure machinery is unavailable (``assist_available=False``), the requests the Assist dimension but the exposure machinery is unavailable
dimension is SKIPPED — hiding nothing by Assist — mirroring the resolver's (``assist_available=False``), the dimension is SKIPPED — hiding nothing by
"skip the dimension when its data is unavailable" fail-open (the paired Assist — mirroring the resolver's fail-open behavior (the paired
degradation warning is surfaced by :func:`_visibility_warnings`). Kept a degradation warning is surfaced by :func:`_visibility_warnings`). Kept a
standalone pure function so it is unit-testable without the full search standalone pure function so it is unit-testable without the full search
pipeline. pipeline.
@@ -5052,29 +5145,25 @@ def _visibility_hidden_set(
allow_areas = set(visibility.get("allow_areas") or []) allow_areas = set(visibility.get("allow_areas") or [])
allow_labels = set(visibility.get("allow_labels") or []) allow_labels = set(visibility.get("allow_labels") or [])
respect_assist = bool(visibility.get("respect_assist_exposure")) respect_assist = bool(visibility.get("respect_assist_exposure"))
# ``assist_active`` gates the per-entity Assist should_expose sub-check (skipped
# when the config asks for Assist but its data is unavailable). The allow/assist
# loop below is guarded by ``allow_active or respect_assist`` — a structural
# mirror of the resolver's guard, not a functional requirement: when Assist is
# requested-but-unavailable and no allowlist is set, the loop runs but hides
# nothing (both sub-checks are inert). Kept identical so the two paths match.
assist_active = respect_assist and assist_available
allow_active = bool(allow_areas or allow_labels or allow_entity_ids)
registry_by_id = _registry_index_by_id(view) if inventory is None:
inventory = _visibility_inventory(view, states, visibility)
registry_by_id = inventory.registry_by_id
# states-only entity universe (YAML/template entities absent from the registry # states-only entity universe (YAML/template entities absent from the registry
# that the allow / Assist dimensions must still be able to hide). # that the allow / Assist dimensions must still be able to hide).
state_ids = { state_ids = inventory.state_ids
eid for eid in (getattr(s, "entity_id", None) for s in states or []) if eid allow_active = inventory.allowlist.active
} authorized = inventory.allowlist.authorized
# Fail-open guard: registry-derived allow dimensions cannot match when the
# Fail-open guard (mirrors the resolver): an area/label allowlist needs registry # registry is empty but states-only candidates exist.
# data to match; if the registry is empty but there are states-only candidates, if inventory.allowlist.degraded:
# restrict mode would blank everything, so drop those allow dimensions.
if (allow_areas or allow_labels) and not registry_by_id and state_ids:
allow_areas = set() allow_areas = set()
allow_labels = set() allow_labels = set()
allow_active = bool(allow_entity_ids)
# An authorizing allowlist skips the broad Assist filter; a legacy wire keeps
# it. Only a full degradation (registry-derived dimensions dropped and no
# allow_entity_ids left) makes ``active`` False and re-enables Assist.
assist_active = respect_assist and assist_available and not authorized
hidden: set[str] = set(denied) hidden: set[str] = set(denied)
_apply_visibility_excludes( _apply_visibility_excludes(
@@ -5086,8 +5175,9 @@ def _visibility_hidden_set(
exclude_areas, exclude_areas,
exclude_labels, exclude_labels,
hidden, hidden,
automatic_excludes_active=not authorized,
) )
if allow_active or respect_assist: if allow_active or assist_active:
_apply_visibility_allow_assist( _apply_visibility_allow_assist(
view, view,
registry_by_id, registry_by_id,
@@ -5099,6 +5189,7 @@ def _visibility_hidden_set(
assist_active, assist_active,
should_expose_fn, should_expose_fn,
hidden, hidden,
allow_authorizes=authorized,
) )
return hidden return hidden
@@ -5109,6 +5200,7 @@ def _visibility_warnings(
visibility: Mapping[str, Any], visibility: Mapping[str, Any],
*, *,
assist_available: bool = True, assist_available: bool = True,
inventory: _VisibilityInventory | None = None,
) -> list[str]: ) -> list[str]:
"""Degradation warnings for a visibility computation, mirroring the resolver. """Degradation warnings for a visibility computation, mirroring the resolver.
@@ -5133,19 +5225,16 @@ def _visibility_warnings(
if unknown: if unknown:
warnings.append(_unknown_categories_warning(unknown)) warnings.append(_unknown_categories_warning(unknown))
allow_areas = set(visibility.get("allow_areas") or []) if inventory is None:
allow_labels = set(visibility.get("allow_labels") or []) inventory = _visibility_inventory(view, states, visibility)
registry_by_id = _registry_index_by_id(view) if inventory.allowlist.degraded:
state_ids = {
eid for eid in (getattr(s, "entity_id", None) for s in states or []) if eid
}
# Empty-registry allowlist fail-open: same guard as _visibility_hidden_set —
# only fires when there are states-only candidates the restrict mode would
# otherwise blank.
if (allow_areas or allow_labels) and not registry_by_id and state_ids:
warnings.append(_ALLOWLIST_REGISTRY_EMPTY_WARNING) warnings.append(_ALLOWLIST_REGISTRY_EMPTY_WARNING)
if bool(visibility.get("respect_assist_exposure")) and not assist_available: if (
visibility.get("respect_assist_exposure")
and not inventory.allowlist.authorized
and not assist_available
):
warnings.append(_ASSIST_UNAVAILABLE_WARNING) warnings.append(_ASSIST_UNAVAILABLE_WARNING)
return warnings return warnings
@@ -5161,6 +5250,15 @@ def _registry_index_by_id(view: _RegistryView) -> dict[str, Any]:
return index return index
def _state_entity_ids(states: Any) -> set[str]:
"""Entity IDs present in the live state-machine snapshot."""
return {
eid
for eid in (getattr(state, "entity_id", None) for state in states or [])
if eid
}
def _apply_visibility_excludes( def _apply_visibility_excludes(
view: _RegistryView, view: _RegistryView,
registry_by_id: dict[str, Any], registry_by_id: dict[str, Any],
@@ -5170,22 +5268,27 @@ def _apply_visibility_excludes(
exclude_areas: set[str], exclude_areas: set[str],
exclude_labels: set[str], exclude_labels: set[str],
hidden: set[str], hidden: set[str],
*,
automatic_excludes_active: bool,
) -> None: ) -> None:
"""Add the exclude-dimension hits to ``hidden`` (registry-derived, deny-first). """Add automatic and hard exclude hits to ``hidden``.
Mirrors the resolver's exclude loop. The empty-set dimensions are inert (``x in Category/HA-hidden filters are skipped for an AUTHORIZING allowlist; concrete
set()`` / ``set() & x`` are falsy), so an inactive dimension hides nothing area/label exclusions remain hard conflicts and always apply. The empty-set
without a guard; only ``exclude_hidden`` is a bool flag and keeps its guard. dimensions are inert (``x in set()`` / ``set() & x`` are falsy), so an inactive
dimension hides nothing without a guard; only ``exclude_hidden`` is a bool flag
and keeps its guard.
""" """
for eid, entry in registry_by_id.items(): for eid, entry in registry_by_id.items():
if eid in denied: if eid in denied:
continue continue
if _enum_value(getattr(entry, "entity_category", None)) in categories: if automatic_excludes_active:
hidden.add(eid) if _enum_value(getattr(entry, "entity_category", None)) in categories:
continue hidden.add(eid)
if exclude_hidden and getattr(entry, "hidden_by", None) is not None: continue
hidden.add(eid) if exclude_hidden and getattr(entry, "hidden_by", None) is not None:
continue hidden.add(eid)
continue
if _effective_area_for_entry(view, entry) in exclude_areas: if _effective_area_for_entry(view, entry) in exclude_areas:
hidden.add(eid) hidden.add(eid)
continue continue
@@ -5204,24 +5307,28 @@ def _apply_visibility_allow_assist(
assist_active: bool, assist_active: bool,
should_expose_fn: Any, should_expose_fn: Any,
hidden: set[str], hidden: set[str],
*,
allow_authorizes: bool,
) -> None: ) -> None:
"""Add allow-restrict + Assist hits to ``hidden`` over registry + states. """Add allow-restrict + Assist hits to ``hidden`` over registry + states.
These conjunctive filters must reach states-only entities, so they iterate the Both filters reach states-only entities. A nonmatching entity is always hidden
full candidate universe. Mirrors the resolver's allow/assist loop. ``should_expose_fn`` while the allowlist is active. With ``allow_authorizes`` a match skips the
is consulted only when ``assist_active`` (Assist requested AND its data Assist check; on a legacy wire a matched entity still faces it, so
available); an unavailable-Assist request degrades to no Assist hiding here, ``should_expose_fn`` is consulted whenever Assist is requested and available.
with the warning surfaced separately.
""" """
for eid in registry_by_id.keys() | state_ids: for eid in registry_by_id.keys() | state_ids:
if eid in hidden: if eid in hidden:
continue continue
entry = registry_by_id.get(eid) entry = registry_by_id.get(eid)
if allow_active and not _entity_allowed( if allow_active:
view, eid, entry, allow_entity_ids, allow_areas, allow_labels if not _entity_allowed(
): view, eid, entry, allow_entity_ids, allow_areas, allow_labels
hidden.add(eid) ):
continue hidden.add(eid)
continue
if allow_authorizes:
continue
if assist_active and not should_expose_fn(eid): if assist_active and not should_expose_fn(eid):
hidden.add(eid) hidden.add(eid)
@@ -5391,6 +5498,7 @@ def _assist_exposure_available(hass: HomeAssistant) -> bool:
DATA_EXPOSED_ENTITIES, DATA_EXPOSED_ENTITIES,
) )
except Exception: except Exception:
_LOGGER.warning("Assist exposure support import failed", exc_info=True)
return False return False
data = getattr(hass, "data", None) data = getattr(hass, "data", None)
return isinstance(data, Mapping) and DATA_EXPOSED_ENTITIES in data return isinstance(data, Mapping) and DATA_EXPOSED_ENTITIES in data
+2 -2
View File
@@ -4,7 +4,7 @@
"version": 1, "version": 1,
"source": "zigbee-herdsman@10.9.1", "source": "zigbee-herdsman@10.9.1",
"internal": { "internal": {
"date": "2026-09-03T19:08:55.796Z", "date": "2026-09-04T13:37:32.664Z",
"znpVersion": 1 "znpVersion": 1
} }
}, },
@@ -25,7 +25,7 @@
"network_key": { "network_key": {
"key": "0a7ed3caf6adb653119697df9d04ec25", "key": "0a7ed3caf6adb653119697df9d04ec25",
"sequence_number": 0, "sequence_number": 0,
"frame_counter": 4342097 "frame_counter": 4395150
}, },
"devices": [ "devices": [
{ {
+17 -17
View File
@@ -4,10 +4,10 @@
"state": "ON", "state": "ON",
"led_brightness": 100, "led_brightness": 100,
"countdown_to_turn_off": 0, "countdown_to_turn_off": 0,
"voltage": 118.3, "voltage": 119.3,
"countdown_to_turn_on": 0, "countdown_to_turn_on": 0,
"ac_frequency": 60, "ac_frequency": 60,
"power_factor": 0.11, "power_factor": 0.15,
"update": { "update": {
"state": "idle", "state": "idle",
"installed_version": 268513381, "installed_version": 268513381,
@@ -45,7 +45,7 @@
"state": "ON", "state": "ON",
"led_brightness": 100, "led_brightness": 100,
"countdown_to_turn_off": 0, "countdown_to_turn_off": 0,
"voltage": 118.2, "voltage": 118.8,
"countdown_to_turn_on": 0, "countdown_to_turn_on": 0,
"energy": 73.98, "energy": 73.98,
"power_factor": 0.2, "power_factor": 0.2,
@@ -74,13 +74,13 @@
"led_brightness": 100, "led_brightness": 100,
"countdown_to_turn_off": 0, "countdown_to_turn_off": 0,
"countdown_to_turn_on": 0, "countdown_to_turn_on": 0,
"voltage": 119.7, "voltage": 120.8,
"state": "ON", "state": "ON",
"ac_frequency": 60, "ac_frequency": 60,
"energy": 150.75, "energy": 150.76,
"power": 97.9, "power": 0.7,
"current": 0.86, "current": 0.05,
"power_factor": 0.95, "power_factor": 0.38,
"update": { "update": {
"state": "idle", "state": "idle",
"installed_version": 268513381, "installed_version": 268513381,
@@ -95,13 +95,13 @@
"led_brightness": 100, "led_brightness": 100,
"countdown_to_turn_off": 0, "countdown_to_turn_off": 0,
"countdown_to_turn_on": 0, "countdown_to_turn_on": 0,
"voltage": 117.8, "voltage": 118.8,
"energy": 71.82, "energy": 71.83,
"state": "ON", "state": "ON",
"power": 72.2, "power": 55.5,
"current": 0.8, "current": 0.64,
"ac_frequency": 60, "ac_frequency": 60,
"power_factor": 0.75, "power_factor": 0.77,
"update": { "update": {
"state": "idle", "state": "idle",
"installed_version": 268513381, "installed_version": 268513381,
@@ -114,12 +114,12 @@
}, },
"0xffffb40e060895b3": { "0xffffb40e060895b3": {
"state": "ON", "state": "ON",
"voltage": 118.6, "voltage": 119.1,
"ac_frequency": 60, "ac_frequency": 60,
"energy": 10.11, "energy": 10.11,
"current": 0.01, "current": 0.01,
"power": 0.1, "power": 0.1,
"power_factor": 0.11, "power_factor": 0.22,
"linkquality": 98, "linkquality": 98,
"update": { "update": {
"state": "idle", "state": "idle",
@@ -136,7 +136,7 @@
"0xffffb40e0608864e": { "0xffffb40e0608864e": {
"led_brightness": 100, "led_brightness": 100,
"countdown_to_turn_off": 0, "countdown_to_turn_off": 0,
"voltage": 119.5, "voltage": 120.1,
"energy": 23.41, "energy": 23.41,
"countdown_to_turn_on": 0, "countdown_to_turn_on": 0,
"state": "ON", "state": "ON",
@@ -224,7 +224,7 @@
}, },
"0xb40e060fffe717c5": { "0xb40e060fffe717c5": {
"battery": 100, "battery": 100,
"occupancy": false, "occupancy": true,
"tamper": false, "tamper": false,
"battery_low": false, "battery_low": false,
"linkquality": 80, "linkquality": 80,