Skip to content

Commit 8bbe987

Browse files
Patch76claude
andauthored
feat: opt-in entity visibility filter for collection read tools (#1728) (#1736)
* feat: add VisibilityConfig model for entity visibility filter * feat: add atomic load/save for entity_visibility.json * feat: add pure resolver for entity visibility hidden set * feat: add fail-open off-loop loader for the visibility hidden set * feat: apply entity visibility filter to ha_search exact + domain paths * feat: apply entity visibility filter to smart_search area + overview paths * test: add e2e + runnable Tier-B coverage for entity visibility filter Add tests/src/e2e/test_entity_visibility.py: exercises the filter end to end through the ha_search MCP tool against a live HA. An enabled denylist removes the entity from search results (with post-filter count coherence), while a targeted ha_get_state still returns it – the Tier-B contract. Marked external_only because the HAOS-inaddon backend runs the server in a separate addon container, where in-process redirection of the config dir cannot reach it; the component path is backend-identical, so the container run covers it. Add a runnable in-process Tier-B test to the visibility unit suite that drives _get_single_entity_state directly, so the "targeted reads are never filtered" contract is covered without Docker. * docs: document the opt-in entity visibility filter * refactor: dedupe entity_id lookups in domain-search filter Bind entity_id once via walrus in the _search_domain_only filter comprehension instead of three separate dict lookups, and unify the membership checks on the same value. Behavior-identical: a missing entity_id short-circuits at the startswith check in both forms. * test: add runnable coverage for visibility filter in get_system_overview Drive get_system_overview in-process with a fake client so the M4 overview wiring has non-e2e coverage: asserts the diagnostic entity is dropped from the universe and that total_entities / total_domains / domain_stats reflect the post-filter set (coherence), plus a disabled-config no-op case. Also correct a now-stale comment: total_entities reflects the visibility-filtered universe, no longer the full system. * test: add end-to-end integration test for _search_domain_only visibility filter Covers the domain-listing search path in-process: an enabled category exclude drops the diagnostic sensor and total_matches reflects the post-filter set (coherence). Closes the last untested end-to-end filter path that had only shared-helper coverage. * style: apply ruff format to visibility test files * fix+test: warn on unusable registry payload + end-to-end coverage for smart_search sites Address pr-review-toolkit findings: - resolver.hidden_entity_ids now logs a warning when the filter is enabled but the registry payload is unusable (non-dict / success:false / non-list result), matching spec §7 'degrade with a warning'. Still fails open. The disabled default stays silent (no per-call warning). - add end-to-end tests driving the full _fetch_search_entities and get_entities_by_area methods (previously only their staticmethod helpers were tested), asserting the diagnostic entity is excluded and area totals stay coherent. - add resolver tests for warn-and-fail-open on bad/exception/non-list payloads and silence when disabled. - reword two comments: results[1]/[2] is the unprojected registry (not literally 'all fields' — entity_registry/list omits aliases). * docs: include managed version field in the visibility config example * fix: correct e2e get_state assertion and harden area-search error paths Follow-up fixes on the entity-visibility filter branch (#1728): - e2e: ha_get_state returns a {data, metadata} envelope with no top-level "success" key, so assert via assert_mcp_success instead of a raw got.get("success") check. This was the sole cause of the red E2E Validation and HAOS E2E lanes (all four failed only on this test). - resolver: coerce a bare-string "labels" value to a single-element list before set.intersection so it is not iterated character-by-character, which could otherwise spuriously hide an entity; add a regression test. - get_entities_by_area: re-raise a failed mandatory states fetch and propagate a cancelled registry sub-task instead of degrading to an empty area result with success=True, mirroring _fetch_search_entities; add regression tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: propagate cancellation in get_system_overview for sibling parity get_system_overview used isinstance(results[0], Exception), so a cancelled mandatory states fetch was assigned to `entities` and then crashed downstream iteration with a TypeError instead of propagating. Switch to BaseException and re-raise a cancelled services/registry sub-task, matching get_entities_by_area and _fetch_search_entities; add a regression test that fails without the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: apply entity visibility denylist to registry-less entities deny_entity_ids only hid entities present in the entity registry, because hidden_entity_ids built the set solely from the registry list. An entity in get_states() with no registry entry (legacy YAML / template entities) was silently not hidden despite an exact deny_entity_ids match. Seed the hidden set from the denylist directly — deny is a literal entity_id match that needs no registry metadata. The registry-derived dimensions (category/area/label/hidden_by) still require a registry entry, and a failed registry read still fails open. Also correct the FAQ: version is reserved, not "managed automatically" — no write path bumps it in production. Add regression tests for the states-only and empty-registry deny cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(visibility): skip non-iterable label payloads per-entry instead of failing whole filter The label check already coerced a bare-string `labels` value to a single-element list. Extend that guard so an unexpected non-iterable payload (int/dict/…) skips only that entry's label intersection rather than raising a TypeError that would fail-open-disable the visibility filter for every other entity. Regression test asserts a malformed sibling is skipped while a well-formed entry is still hidden. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(visibility): honor deny_entity_ids on degraded-registry read The two unusable-registry early returns dropped the deny seed, so a transient registry read failure silently un-hid explicitly denied entities. deny is a literal entity_id match and needs no registry data; return it on both degraded paths instead of failing fully open. Only the registry-derived dimensions degrade to open on a bad read. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(search): raise instead of dead return None in get_system_overview exception_to_structured_error is NoReturn, so the trailing return None was dead. Re-raise like the sibling handlers get_entities_by_area and _fetch_search_entities for a consistent, unambiguous exit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(visibility): surface degradation and dropped categories via warnings The resolver now returns (hidden, warnings): an enabled-but-degraded read (registry unavailable) and dropped unknown exclude_categories are carried back as caller-facing warnings instead of only being logged, and each ha_search / ha_get_overview seam threads them into the top-level response warnings list. Unknown categories are dropped (warn-and-drop), not hard-rejected, so a future HA category can never fail the whole config. Addresses review points 2 and 4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(visibility): add settings-UI config GET/PUT handlers with optimistic lock Serve entity_visibility.json over /api/visibility/config (GET/PUT) from the settings UI, mirroring the tool-policy handlers: version-mismatch returns 409, corrupt file returns 500, invalid payload 400. This gives save_visibility_config and the version field their consumer (addressing review point 1) and makes the config editable without hand-authoring the file on disk. Always registered (pure file I/O, no approval-queue dependency). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(visibility): tidy warnings threading after simplify pass - Move merge_visibility_warnings to tools/util_helpers.py (generic response helper) so visibility/resolver.py stays pure-resolver. - Drop redundant set() copies on the two degraded-registry returns; denied is already a set and callers treat it read-only. - Use the compositional merge in the domain-only search return for consistency with the other seams. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(visibility): add Entity Visibility settings-UI tab A form-based tab (enable toggle, category checkboxes, area/label text fields, per-entity denylist textarea) that reads and writes entity_visibility.json via the /api/visibility/config GET/PUT handlers, with the same optimistic-lock reload-on-409 flow as the policy tab. Tab switching, roving tabindex and arrow-key navigation come for free from activateTab (one button + one panel). The "noise reduction, not access control" caveat is shown at the top of the panel so add-on users do not mistake it for a security boundary. Live registry pickers for areas/labels/devices are deferred to a follow-up; this ships the denylist config the resolver already honors, editable without hand-writing the file. Panel added to the tablist a11y guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(visibility): cover shipped default + registry-derived label dimension - Unit: enabled-default config fires the shipped ["diagnostic","config"] and proves both categories hide (config was never exercised before). - E2E: a label assigned to the probe helper is hidden via exclude_labels, exercising the registry-join path, not just the registry-independent denylist. - Drop the "not executed locally" authoring note from the e2e docstring; keep the config-dir-seam and external_only rationale. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(visibility): FAQ reads-only rationale, settings UI, warn-drop behavior State that the filter is read-scoping only and does not gate control tools (writes stay with the Tool Security Policies engine), point users at the new Entity Visibility settings tab, and document the constrained categories, warn-and-drop on unknown values, version optimistic-lock, and degraded-read warning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(visibility): add allowlist + respect-Assist-exposure dimensions Complete #1728 by folding the two remaining dimensions into the opt-in entity visibility filter alongside the shipped deny/exclude dimensions. - allow_entity_ids / allow_areas / allow_labels: when any is set the filter inverts to restrict mode (only matched entities stay visible). The resolver now takes the seam's states list so states-only (YAML/template) entities are covered too, not just the registry. - respect_assist_exposure: reconstructs HA-core async_should_expose for the conversation assistant (explicit per-entity override wins; else, when the instance exposes new entities, domain + device-class defaults decide). Two websocket reads are fetched once per search when enabled and fail soft (the dimension is skipped with a warning rather than hiding everything). - Mirror HA's DEFAULT_EXPOSED_DOMAINS + binary_sensor/sensor device-class sets; device_class comes from the registry override or the live state attribute. - Settings-UI fields for both; FAQ documents the conjunction model. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(e2e): bump haos_inaddon skip ceiling for visibility e2e tests The entity-visibility filter adds @external_only e2e tests (denylist/label/allowlist) that skip on the haos_inaddon lane, raising the observed skip count to 60, over the prior ceiling of 58. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(visibility): read explicit Assist should_expose from registry options The respect_assist_exposure dimension reconstructed exposure from homeassistant/expose_entity/list, which HA-core only ever returns exposed (True) entities in — an explicit per-entity un-expose is omitted entirely, so it could never be honored: an explicitly un-exposed default-domain entity (light, switch) stayed visible while HA's async_should_expose returns False. Read the explicit should_expose (True or False, incl. HA's persisted computed defaults) from each registry entry's options via config/entity_registry/get_entries (mirrors _fetch_area_entity_aliases), replacing the expose_entity/list read. WebSocket-read count stays at 2. States-only entities have no registry options and fall to the default branch — documented in the FAQ as a residual limit. Replaces the seam test's impossible {"conversation": False} list fixture with a faithful get_entries fixture and adds the expose_new=True + explicit-unexpose regression case. * fix(visibility): forward area-path visibility warnings to the response ha_search's three area builders (_search_area_with_query, _search_area_only_populated, _search_area_only) rebuild a fresh response dict and never carried area_result's warnings, so a visibility/registry degradation on ha_search(area_filter=...) was silently invisible while the non-area path surfaced it. Forward area_result["warnings"] once at the area dispatch (mirrors the non-area merge_visibility_warnings). Add the missing "warnings last mile" coverage that let this slip: direct tests for merge_visibility_warnings and response-level assertions that a corrupt config surfaces a warning on the ha_search and ha_get_overview responses. The area path itself gets a response-level assertion via the e2e (corrupt config). * test(visibility): cover allow + exclude dimensions active together Every allowlist test zeroed exclude_categories, so the central invariant — hide dimensions compose independently — was proven one dimension at a time. A refactor that let restrict mode skip the exclude loop would pass the suite while leaking an allowed-but-diagnostic entity. Add a resolver test and a seam test (_exact_match_search) with an allow and an exclude dimension simultaneously active, asserting exclude wins over allow and the allowlist still restricts. * fix(visibility): explicit extra-ignore, empty-registry allowlist guard, partial alignment Round-2 small items: - Make VisibilityConfig's extra="ignore" explicit with the forward-compat rationale (a typo'd key stays silently dropped; documented, not incidental). - Fail-open guard: a success-but-empty entity registry plus an area/label allowlist would hide every states-only candidate (the fail-closed blank the design forbids). Degrade those registry-derived allow dimensions to open and warn; allow_entity_ids still applies. - ha_get_overview no longer marks itself partial for a mere visibility warning (config typo / skipped Assist dimension) on otherwise-complete data — partial now reflects genuinely incomplete data only, matching ha_search. - FAQ: state that excludes (not only deny) win over an allow_entity_ids match. * fix(visibility): settings-tab a11y + non-clobbering 409, with JS behaviour tests - Give the visibility-load-error region role="alert" aria-live="assertive" so a failed config load is announced (it had neither). - Route visibilitySaveConfig's failure branches through setStatusAlert so the save status flips to role="alert" on error (network, HTTP, 409) instead of staying a polite status, matching every sibling save path. - The 409 optimistic-lock branch no longer auto-reloads the config: that overwrote the user's unsaved edits while telling them to "re-apply". Keep the edits, announce the conflict, and let the user reload deliberately (mirrors the policy tab). - Add behavioural JS tests (load failure, save success/failure status role, and the 409 keep-edits-no-reload flow) driving the real script through JSDOM. * fix(visibility): forward area warnings inside the unwrapped data payload The prior area-warning forward attached warnings to the top level of the area builders' response, but those builders wrap their payload via add_timezone_metadata into {"data": {...}, "metadata": {...}} and the ha_search orchestrator unwraps ["data"] before merging metadata (_apply_search_outcome) — so top-level warnings were dropped again and never reached the response. Merge the warnings into ["data"] so they survive the unwrap. Add a unit test driving the real ha_search orchestrator (fake smart-tools returning an area result with a warning) that asserts the warning reaches the response — it fails against the previous top-level forward. * test(e2e): allowlist positive/deny-wins, overview, and assist-unexpose coverage Round-2 e2e gaps, each through real MCP dispatch against live HA: - Allowlist: assert the positive side (an allowlisted entity stays visible) plus deny-wins-over-allow — the prior allowlist e2e only asserted the unlisted probe was hidden, which a hide-everything regression would still pass. - ha_get_overview: the second filtered collection tool had zero e2e; assert a denied probe drops out of the overview entity total. - respect_assist_exposure: explicitly expose then un-expose a probe and assert it becomes hidden — the regression for the round-2 Assist finding (explicit should_expose=False now read from the registry options via get_entries). Bump the haos_inaddon skip ceiling 60 -> 63 for the 3 new @external_only tests. * fix(visibility): read Assist exposure from registry payload, restore states-only expose Round-2 read per-entity conversation exposure via config/entity_registry/get_entries. That was redundant – the config/entity_registry/list payload the resolver already holds carries each entry's options (HA's as_partial_dict includes options) – and it dropped homeassistant/expose_entity/list, the only read reaching HA's separate exposed-entities store for states-only (non-registry) entities. A states-only entity a user explicitly exposed to Assist was then silently hidden, violating the module's fail-open contract. Read each registry entry's explicit should_expose (True or False) directly from the list payload options, and re-add expose_entity/list as a True-only source for ids with no registry entry (it cannot express False; for a states-only entity that direction stays fail-open). Net websocket reads stay at two. Delete the get_entries fetch and its _registry_entity_ids plumbing. Add a seam test for the states-only explicit-expose case and a services-failure overview test pinning partial:true beside a coexisting visibility warning. Correct the seam fixture, FAQ residual note, and docstrings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(visibility): match device-inherited area and labels The area/label exclude and allow dimensions matched only an entity's own area_id/labels, but most entities are device-bound (registry area_id null + a device_id) and inherit their area from the device registry; device labels apply to their entities. exclude_areas under-hid those entities and allow_areas over-hid them (hiding entities in the allowed area, the fail-closed blank the module forbids). Live-verified on a real instance. Thread config/device_registry/list into the resolver and match effective area (entity area_id else device) and effective labels (entity union device) in both the exclude and allow loops. A degraded or absent device payload leaves the maps empty, so the dimensions fall back to entity-level matching (fail open). Wire the device registry through all five load_hidden_set call sites: three (_fetch_search_entities and the two tools_search paths) had no device fetch and gained config/device_registry/list in their gather; two (get_entities_by_area, get_system_overview) already fetched it. Also reword the deep-search progress line so its raw pre-filter state count does not read as "the visibility filter is off". Add device-bound resolver fixtures (entity area_id null, device carries the area/label) covering both exclude and allow directions, entity-over-device precedence, the entity-union-device label rule, and the no-device fallback, plus an overview seam test proving the call-site wiring reaches the resolver. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(visibility): bump embedded/inaddon skip ceilings for the visibility e2e The master merge brought the embedded and haos_embedded e2e lanes (#1741); this branch's entity-visibility e2e (test_entity_visibility.py, @external_only, they need a real external MCP server) skip on those backends and on the inaddon lane, pushing each lane's skip-marker count past its ceiling. Set each ceiling to the CI-observed count: haos_inaddon 62 -> 65, embedded 119 -> 125, haos_embedded 90 -> 94. The external lane still runs the tests (real coverage); the skips are the same @external_only class the existing ceilings already account for, not a broadened marker. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 813b97f commit 8bbe987

28 files changed

Lines changed: 3306 additions & 59 deletions

docs/FAQ.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,93 @@ The version should match the [latest release](https://github.com/homeassistant-a
311311
| `weak` | Rarely suggests backups |
312312
| `auto` | Same as normal (future: auto-detection) |
313313

314+
### Entity visibility filter (opt-in)
315+
316+
By default the agent sees every entity. If auto-generated diagnostic or helper
317+
entities clutter search and overview results, you can hide a chosen set of them
318+
from the *collection* read tools (`ha_search`, `ha_get_overview`). This is
319+
**noise reduction, not access control** – a hidden entity is still returned by a
320+
direct `ha_get_state` / `ha_get_entity` on its `entity_id`, and still appears in
321+
automation, dashboard, and template content, so do not rely on it as a security
322+
boundary.
323+
324+
**Reads only – it does not gate control tools.** The filter scopes what the
325+
*collection read* tools return. It does **not** stop an agent from calling a
326+
service on a hidden `entity_id`: gating writes is a separate concern handled by
327+
the Tool Security Policies engine (which matches on a call's arguments), not by
328+
visibility. Visibility is deliberately read-scoping only, precisely because it
329+
is noise reduction and cannot be a security boundary (content-bearing reads such
330+
as automation and template bodies would leak hidden entities anyway).
331+
332+
The easiest way to configure it is the **Entity Visibility** tab in the ha-mcp
333+
settings UI (enable toggle, category checkboxes, area/label fields, per-entity
334+
denylist). It reads and writes the same file described below, so either surface
335+
works.
336+
337+
The filter is off until `entity_visibility.json` exists in the ha-mcp data
338+
directory (the same directory as `tool_policy.json`; `/data` in the add-on) with
339+
`"enabled": true`:
340+
341+
```json
342+
{
343+
"version": 1,
344+
"enabled": true,
345+
"exclude_categories": ["diagnostic", "config"],
346+
"exclude_hidden": false,
347+
"deny_entity_ids": [],
348+
"exclude_areas": [],
349+
"exclude_labels": [],
350+
"allow_entity_ids": [],
351+
"allow_areas": [],
352+
"allow_labels": [],
353+
"respect_assist_exposure": false
354+
}
355+
```
356+
357+
The filter is a conjunction of independent dimensions: an entity is shown only if
358+
it passes every active one.
359+
360+
- **Excludes / denylist.** An entity is hidden when its `entity_category` is in
361+
`exclude_categories`, its `entity_id` is in `deny_entity_ids`, or its area/label
362+
is in `exclude_areas` / `exclude_labels`. `exclude_categories` accepts only Home
363+
Assistant's two entity categories (`diagnostic`, `config`); an unknown value is
364+
ignored and surfaced as a `warnings` entry on the next read rather than silently
365+
doing nothing. Set `exclude_hidden: true` to also fold in entities already
366+
marked hidden in Home Assistant.
367+
- **Allowlist.** The moment any of `allow_entity_ids` / `allow_areas` /
368+
`allow_labels` is non-empty, the filter inverts to *restrict* mode: only
369+
entities matching an allowlist stay visible and everything else – including
370+
entities added later – is hidden. Leave all three empty to keep the allowlist
371+
off. `deny_entity_ids` still wins over an allow match — and so does any
372+
`exclude_*` match: an entity an allowlist would admit but an
373+
`exclude_categories` / `exclude_areas` / `exclude_labels` also hides stays
374+
hidden (every dimension can only hide, so any one hide is enough — the allow
375+
dimensions cannot un-hide what another dimension excluded).
376+
- **Respect Assist exposure.** With `respect_assist_exposure: true` the filter
377+
hides entities not effectively exposed to Home Assistant's Assist
378+
(`conversation`) assistant, mirroring `async_should_expose` (an explicit
379+
per-entity exposure override wins; otherwise, if the instance exposes new
380+
entities, the entity's domain and device-class defaults decide). Because HA
381+
offers no single "effective exposure" API, the decision is reconstructed
382+
client-side from two extra websocket reads per search — the set of entities
383+
explicitly exposed to the assistant (`expose_entity/list`, which reports only
384+
the *exposed* ones) and the "expose new entities" flag that drives the default
385+
branch; if either read fails the dimension is skipped with a `warnings` note
386+
rather than hiding everything. A registry entity's explicit override — exposed
387+
*or* un-exposed — is read directly from the entity-registry `options` the
388+
registry list already carries, so an explicit un-expose is honored. One residual
389+
limit: for an entity that lives only in the state machine (a YAML/template entity
390+
with no entity-registry entry), HA surfaces it through `expose_entity/list` only
391+
when it is *exposed*; an explicit un-expose cannot be observed there, so such an
392+
entity falls to its domain/device-class default and stays visible (fail-open).
393+
394+
`version` drives optimistic-concurrency for the settings UI (it bumps
395+
on each save so two tabs can't clobber each other); when hand-editing the file,
396+
leave it as-is. The config is read live per request, so edits apply on the next
397+
call; a missing or invalid file leaves the filter off (and, when enabled but the
398+
registry read degrades, results are unfiltered with a `warnings` note rather than
399+
silently wrong).
400+
314401
---
315402

316403
## Feedback & Help

src/ha_mcp/settings_ui/__init__.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,61 @@ async def unavailable(_: Request) -> JSONResponse:
796796
}
797797

798798

799+
def _build_visibility_handlers(*, data_dir: Path) -> dict[str, Any]:
800+
"""Settings-UI handlers for the entity visibility filter config.
801+
802+
Serves the on-disk ``entity_visibility.json`` GET/PUT with the same
803+
optimistic-concurrency (version) guard as the tool policy handlers. Pure
804+
file I/O, so it is always available (no approval-queue dependency).
805+
"""
806+
from pydantic import ValidationError
807+
808+
from ..visibility.model import VisibilityConfig
809+
from ..visibility.persistence import (
810+
load_visibility_config,
811+
save_visibility_config,
812+
)
813+
814+
async def get_config(_: Request) -> JSONResponse:
815+
try:
816+
return JSONResponse(
817+
load_visibility_config(data_dir).model_dump(mode="json")
818+
)
819+
except ValueError as e:
820+
# Surface corruption rather than crash the tab on a 500.
821+
return JSONResponse(
822+
{"error": str(e), "visibility_file_corrupt": True},
823+
status_code=500,
824+
)
825+
826+
async def put_config(request: Request) -> JSONResponse:
827+
try:
828+
new_config = VisibilityConfig.model_validate(await request.json())
829+
except (ValidationError, ValueError) as e:
830+
return JSONResponse({"error": str(e)}, status_code=400)
831+
# Optimistic concurrency: reject if the on-disk version moved between
832+
# this caller's GET and PUT.
833+
current = load_visibility_config(data_dir)
834+
if new_config.version != current.version:
835+
return JSONResponse(
836+
{
837+
"error": (
838+
"Visibility config version mismatch. Reload before saving."
839+
),
840+
"current_version": current.version,
841+
"current_config": current.model_dump(mode="json"),
842+
},
843+
status_code=409,
844+
)
845+
save_visibility_config(data_dir, new_config)
846+
return JSONResponse({"saved": True, "version": new_config.version + 1})
847+
848+
return {
849+
"visibility_get_config": get_config,
850+
"visibility_put_config": put_config,
851+
}
852+
853+
799854
class _SupervisorOptionsError(NamedTuple):
800855
"""Discriminated failure shape for the supervisor options helpers.
801856
@@ -3237,6 +3292,8 @@ async def _save_fs_custom_paths(request: Request) -> JSONResponse:
32373292
else:
32383293
handlers.update(_build_stub_policy_handlers(data_dir=get_data_dir()))
32393294

3295+
handlers.update(_build_visibility_handlers(data_dir=get_data_dir()))
3296+
32403297
return handlers
32413298

32423299

@@ -3397,6 +3454,9 @@ def register_settings_routes(
33973454
("/api/policy/deny", ["POST"], "policy_post_deny"),
33983455
("/api/policy/tool-schema", ["GET"], "policy_get_tool_schema"),
33993456
("/api/policy/value-source", ["GET"], "policy_get_value_source"),
3457+
# Entity visibility filter endpoints (issue #1728)
3458+
("/api/visibility/config", ["GET"], "visibility_get_config"),
3459+
("/api/visibility/config", ["PUT"], "visibility_put_config"),
34003460
]
34013461

34023462
def _mount(prefix: str, *, guard: bool = False) -> None:

src/ha_mcp/settings_ui/settings.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -953,3 +953,18 @@
953953
/* Modal fills the small viewport instead of floating in a sliver. */
954954
.modal { max-height: 94vh; }
955955
}
956+
957+
/* Entity visibility filter tab (#1728) */
958+
.visibility-field {
959+
display: block; margin: 10px 0; color: var(--text-secondary); font-size: 0.85rem;
960+
}
961+
.visibility-field input[type="text"], .visibility-textarea {
962+
display: block; width: 100%; margin-top: 4px; padding: 6px 8px;
963+
background: var(--surface); color: var(--text); border: 1px solid var(--border);
964+
border-radius: 6px; font: inherit;
965+
}
966+
.visibility-textarea { resize: vertical; }
967+
.visibility-error {
968+
background: var(--danger); color: #fff; padding: 8px 12px;
969+
border-radius: 6px; margin-bottom: 8px; font-size: 0.85rem;
970+
}

src/ha_mcp/settings_ui/settings.html

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ <h1>HA-MCP Settings</h1>
158158
<button class="tab" data-panel="server" role="tab" id="tab-server" aria-controls="panel-server" aria-selected="false" tabindex="-1">Server Settings</button>
159159
<button class="tab" data-panel="backups" role="tab" id="tab-backups" aria-controls="panel-backups" aria-selected="false" tabindex="-1">Backups</button>
160160
<button class="tab" data-panel="tool-security-policies" role="tab" id="tab-tool-security-policies" aria-controls="panel-tool-security-policies" aria-selected="false" tabindex="-1">Tool Security Policies</button>
161+
<button class="tab" data-panel="entity-visibility" role="tab" id="tab-entity-visibility" aria-controls="panel-entity-visibility" aria-selected="false" tabindex="-1">Entity Visibility</button>
161162
<button class="tab" data-panel="accessibility" role="tab" id="tab-accessibility" aria-controls="panel-accessibility" aria-selected="false" tabindex="-1">Accessibility</button>
162163
</div>
163164
<div class="restart-notice" id="restartNotice">
@@ -403,6 +404,70 @@ <h2 class="a11y-section-title">Custom colors</h2>
403404
<button id="a11y-reset" class="restart-btn" type="button">Reset to defaults</button>
404405
</section>
405406
</div>
407+
<div class="panel" id="panel-entity-visibility" role="tabpanel" aria-labelledby="tab-entity-visibility" tabindex="0">
408+
<p class="tool-desc" style="margin-bottom:12px">
409+
Hide low-value entities (auto-generated diagnostic or helper entities, or an
410+
explicit list) from the collection read tools <code>ha_search</code> and
411+
<code>ha_get_overview</code>, so search and overview results are less
412+
cluttered. Off by default; changes apply on the next tool call.
413+
</p>
414+
<p class="a11y-contrast-warning" id="visibility-boundary-note">
415+
Noise reduction, not access control: a hidden entity is still returned by a
416+
direct read of its entity_id and still appears in automation, dashboard, and
417+
template content. Do not rely on this as a security boundary.
418+
</p>
419+
<div id="visibility-load-error" class="visibility-error" role="alert" aria-live="assertive" style="display:none"></div>
420+
421+
<section class="a11y-section">
422+
<label class="a11y-option"><input type="checkbox" id="visibility-enabled"> Enable entity visibility filter</label>
423+
</section>
424+
425+
<section class="a11y-section">
426+
<h2 class="a11y-section-title">Hide by category</h2>
427+
<p class="a11y-section-help">Home Assistant tags auto-generated entities as diagnostic or config.</p>
428+
<fieldset class="a11y-options">
429+
<legend class="visually-hidden">Entity categories to hide</legend>
430+
<label class="a11y-option"><input type="checkbox" id="visibility-cat-diagnostic"> Diagnostic</label>
431+
<label class="a11y-option"><input type="checkbox" id="visibility-cat-config"> Config</label>
432+
<label class="a11y-option"><input type="checkbox" id="visibility-exclude-hidden"> Also entities already hidden in Home Assistant</label>
433+
</fieldset>
434+
</section>
435+
436+
<section class="a11y-section">
437+
<h2 class="a11y-section-title">Hide by area or label</h2>
438+
<p class="a11y-section-help">Comma-separated area IDs or label IDs; entities in any listed area or label are hidden.</p>
439+
<label class="visibility-field">Areas <input type="text" id="visibility-areas" placeholder="garage, basement"></label>
440+
<label class="visibility-field">Labels <input type="text" id="visibility-labels" placeholder="noise, archived"></label>
441+
</section>
442+
443+
<section class="a11y-section">
444+
<h2 class="a11y-section-title">Hide specific entities</h2>
445+
<p class="a11y-section-help">One entity_id per line. These stay hidden even without an entity-registry entry.</p>
446+
<label class="visibility-field visually-hidden" for="visibility-deny">Denied entity IDs</label>
447+
<textarea id="visibility-deny" class="visibility-textarea" rows="4" placeholder="sensor.example_diagnostic&#10;light.unused"></textarea>
448+
</section>
449+
450+
<section class="a11y-section">
451+
<h2 class="a11y-section-title">Restrict to an allowlist</h2>
452+
<p class="a11y-section-help">Advanced: when any allowlist field is set the filter inverts – only entities matching an allowlist stay visible, everything else is hidden (including entities added later). Leave all three empty to disable.</p>
453+
<label class="visibility-field">Allowed areas <input type="text" id="visibility-allow-areas" placeholder="living_room, kitchen"></label>
454+
<label class="visibility-field">Allowed labels <input type="text" id="visibility-allow-labels" placeholder="voice, main_floor"></label>
455+
<p class="a11y-section-help">Allowed entity IDs, one per line.</p>
456+
<label class="visibility-field visually-hidden" for="visibility-allow-entities">Allowed entity IDs</label>
457+
<textarea id="visibility-allow-entities" class="visibility-textarea" rows="3" placeholder="light.living_room&#10;switch.desk"></textarea>
458+
</section>
459+
460+
<section class="a11y-section">
461+
<h2 class="a11y-section-title">Respect Assist exposure</h2>
462+
<p class="a11y-section-help">Hide entities not exposed to Home Assistant's Assist (&ldquo;conversation&rdquo;) voice assistant, mirroring the Settings &gt; Voice assistants &gt; Expose list. Adds two websocket reads per search while enabled.</p>
463+
<label class="a11y-option"><input type="checkbox" id="visibility-respect-assist"> Only show entities exposed to Assist</label>
464+
</section>
465+
466+
<section class="a11y-section">
467+
<button id="visibility-save-btn" class="restart-btn" type="button">Save</button>
468+
<span id="visibility-save-status" class="status" role="status" aria-live="polite"></span>
469+
</section>
470+
</div>
406471
</main>
407472
<div class="modal-backdrop" id="modalBackdrop">
408473
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle" tabindex="-1">

0 commit comments

Comments
 (0)