ha-mcp-dev 7.4.1.dev469__tar.gz → 7.4.1.dev471__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ha_mcp_dev-7.4.1.dev469/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev471}/PKG-INFO +1 -1
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/pyproject.toml +1 -1
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/server.py +13 -6
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/smart_search.py +192 -32
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_search.py +386 -152
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/util_helpers.py +47 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/utils/fuzzy_search.py +162 -19
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/LICENSE +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/README.md +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/setup.cfg +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/settings_ui.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_code.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/utils/data_paths.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/tests/test_env_manager.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ha-mcp-dev"
|
|
7
|
-
version = "7.4.1.
|
|
7
|
+
version = "7.4.1.dev471"
|
|
8
8
|
description = "Home Assistant MCP Server - Complete control of Home Assistant through MCP"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.13,<3.14"
|
|
@@ -21,6 +21,7 @@ from mcp.types import Icon
|
|
|
21
21
|
|
|
22
22
|
from .config import _PACKAGE_VERSION, get_global_settings
|
|
23
23
|
from .tools.enhanced import EnhancedToolsMixin
|
|
24
|
+
from .tools.util_helpers import strip_internal_fields
|
|
24
25
|
from .transforms import DEFAULT_PINNED_TOOLS
|
|
25
26
|
|
|
26
27
|
if TYPE_CHECKING:
|
|
@@ -930,13 +931,19 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
|
|
|
930
931
|
return await self.client.call_service(domain, service, service_data)
|
|
931
932
|
|
|
932
933
|
async def get_entities_by_area(self, area_name: str) -> dict[str, Any]:
|
|
933
|
-
"""Bridge method to existing area functionality.
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
934
|
+
"""Bridge method to existing area functionality.
|
|
935
|
+
|
|
936
|
+
``smart_tools.get_entities_by_area`` enriches per-entity dicts
|
|
937
|
+
with leading-underscore internals (``_hidden_by`` etc.) so
|
|
938
|
+
downstream search branches can apply the score penalty without
|
|
939
|
+
a second registry lookup. Strip them here so this public bridge
|
|
940
|
+
doesn't leak internals to MCP clients.
|
|
941
|
+
"""
|
|
942
|
+
result = await self.smart_tools.get_entities_by_area(
|
|
943
|
+
area_query=area_name, group_by_domain=True
|
|
939
944
|
)
|
|
945
|
+
strip_internal_fields(result)
|
|
946
|
+
return cast(dict[str, Any], result)
|
|
940
947
|
|
|
941
948
|
async def start(self) -> None:
|
|
942
949
|
"""Start the Smart MCP server with async compatibility."""
|
|
@@ -113,9 +113,10 @@ class SmartSearchTools:
|
|
|
113
113
|
offset: int = 0,
|
|
114
114
|
include_attributes: bool = False,
|
|
115
115
|
domain_filter: str | None = None,
|
|
116
|
+
include_hidden: bool = True,
|
|
116
117
|
) -> dict[str, Any]:
|
|
117
118
|
"""
|
|
118
|
-
|
|
119
|
+
Search entities with fuzzy matching and typo tolerance.
|
|
119
120
|
|
|
120
121
|
Args:
|
|
121
122
|
query: Search query (can be partial, with typos)
|
|
@@ -123,16 +124,120 @@ class SmartSearchTools:
|
|
|
123
124
|
offset: Number of results to skip for pagination
|
|
124
125
|
include_attributes: Whether to include full entity attributes
|
|
125
126
|
domain_filter: Optional domain to filter entities before search (e.g., "light", "sensor")
|
|
127
|
+
include_hidden: When True (default), entities with ``hidden_by``
|
|
128
|
+
set in the entity registry are still returned but receive
|
|
129
|
+
a score penalty so they sort below comparable visible
|
|
130
|
+
matches. Pass False to filter them out entirely.
|
|
126
131
|
|
|
127
132
|
Returns:
|
|
128
133
|
Dictionary with search results and metadata
|
|
129
134
|
"""
|
|
130
135
|
try:
|
|
131
|
-
#
|
|
132
|
-
|
|
136
|
+
# HA domains are canonically lowercase and unpadded; defend
|
|
137
|
+
# the service layer so internal callers get the same
|
|
138
|
+
# normalization the tool layer applies (strip + lowercase
|
|
139
|
+
# before the prefix match downstream).
|
|
140
|
+
if domain_filter:
|
|
141
|
+
domain_filter = domain_filter.strip().lower()
|
|
142
|
+
# Fetch states + entity registry list in parallel. The slim
|
|
143
|
+
# ``list`` view gives us ``hidden_by`` (used to filter
|
|
144
|
+
# UI-hidden entities by default) and the entity_ids we need
|
|
145
|
+
# to feed into ``get_entries`` for the full-fidelity data
|
|
146
|
+
# (aliases live only in get_entries, not the slim list).
|
|
147
|
+
entities_task = self.client.get_states()
|
|
148
|
+
entity_registry_task = self.client.send_websocket_message(
|
|
149
|
+
{"type": "config/entity_registry/list"}
|
|
150
|
+
)
|
|
151
|
+
results = await asyncio.gather(
|
|
152
|
+
entities_task, entity_registry_task, return_exceptions=True
|
|
153
|
+
)
|
|
154
|
+
# States-fetch failure is fatal — auth/connection errors must
|
|
155
|
+
# propagate so the caller sees the real cause instead of a
|
|
156
|
+
# bogus "zero matches" with success=True.
|
|
157
|
+
if isinstance(results[0], BaseException):
|
|
158
|
+
raise results[0]
|
|
159
|
+
# CancelledError on the registry task must propagate too;
|
|
160
|
+
# gather captures it like any other exception when
|
|
161
|
+
# return_exceptions=True.
|
|
162
|
+
if isinstance(results[1], asyncio.CancelledError):
|
|
163
|
+
raise results[1]
|
|
164
|
+
entities = results[0]
|
|
133
165
|
|
|
134
|
-
#
|
|
135
|
-
#
|
|
166
|
+
# Build entity_id -> slim registry entry map. Registry-list
|
|
167
|
+
# failure is tolerated: search continues without alias /
|
|
168
|
+
# hidden awareness rather than failing the whole call.
|
|
169
|
+
registry_slim: dict[str, dict[str, Any]] = {}
|
|
170
|
+
if isinstance(results[1], dict) and results[1].get("success"):
|
|
171
|
+
for entry in results[1].get("result", []):
|
|
172
|
+
eid = entry.get("entity_id")
|
|
173
|
+
if eid:
|
|
174
|
+
registry_slim[eid] = entry
|
|
175
|
+
|
|
176
|
+
# First pass: hidden filter + collect entity_ids for the
|
|
177
|
+
# alias batch fetch. Pre-filtering shrinks the get_entries
|
|
178
|
+
# payload on installations with thousands of entities.
|
|
179
|
+
survivor_ids: list[str] = []
|
|
180
|
+
survivor_states: list[dict[str, Any]] = []
|
|
181
|
+
for entity in entities:
|
|
182
|
+
eid = entity.get("entity_id", "")
|
|
183
|
+
if not eid:
|
|
184
|
+
continue
|
|
185
|
+
slim = registry_slim.get(eid, {})
|
|
186
|
+
hidden_by = slim.get("hidden_by")
|
|
187
|
+
if hidden_by is not None and not include_hidden:
|
|
188
|
+
continue
|
|
189
|
+
survivor_ids.append(eid)
|
|
190
|
+
survivor_states.append(entity)
|
|
191
|
+
|
|
192
|
+
# Second pass: batch-fetch full registry entries for aliases.
|
|
193
|
+
# ``config/entity_registry/list`` deliberately omits
|
|
194
|
+
# ``aliases``; ``get_entries`` includes them. One extra
|
|
195
|
+
# round-trip enriches the survivor set without N+1 fan-out.
|
|
196
|
+
aliases_map: dict[str, list[str]] = {}
|
|
197
|
+
if survivor_ids:
|
|
198
|
+
try:
|
|
199
|
+
entries_resp = await self.client.send_websocket_message({
|
|
200
|
+
"type": "config/entity_registry/get_entries",
|
|
201
|
+
"entity_ids": survivor_ids,
|
|
202
|
+
})
|
|
203
|
+
if (
|
|
204
|
+
isinstance(entries_resp, dict)
|
|
205
|
+
and entries_resp.get("success")
|
|
206
|
+
):
|
|
207
|
+
for eid, entry in (
|
|
208
|
+
entries_resp.get("result", {}) or {}
|
|
209
|
+
).items():
|
|
210
|
+
if isinstance(entry, dict):
|
|
211
|
+
aliases_map[eid] = entry.get("aliases", []) or []
|
|
212
|
+
else:
|
|
213
|
+
logger.warning(
|
|
214
|
+
"alias_enrichment_failed: get_entries returned "
|
|
215
|
+
"non-success for %d entities (resp=%r)",
|
|
216
|
+
len(survivor_ids),
|
|
217
|
+
entries_resp,
|
|
218
|
+
)
|
|
219
|
+
except (KeyError, TypeError, AttributeError) as alias_err:
|
|
220
|
+
logger.warning(
|
|
221
|
+
"alias_enrichment_failed: malformed payload for "
|
|
222
|
+
"%d entities (err=%r)",
|
|
223
|
+
len(survivor_ids),
|
|
224
|
+
alias_err,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Enrich entities with aliases + hidden_by for the fuzzy layer.
|
|
228
|
+
enriched: list[dict[str, Any]] = []
|
|
229
|
+
for entity, eid in zip(survivor_states, survivor_ids, strict=True):
|
|
230
|
+
slim = registry_slim.get(eid, {})
|
|
231
|
+
# Shallow copy + private-prefixed keys so downstream
|
|
232
|
+
# consumers that round-trip these dicts don't ship
|
|
233
|
+
# internal fields back to clients.
|
|
234
|
+
enriched.append({
|
|
235
|
+
**entity,
|
|
236
|
+
"_aliases": aliases_map.get(eid, []),
|
|
237
|
+
"_hidden_by": slim.get("hidden_by"),
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
entities = enriched
|
|
136
241
|
if domain_filter:
|
|
137
242
|
entities = [
|
|
138
243
|
e
|
|
@@ -159,19 +264,12 @@ class SmartSearchTools:
|
|
|
159
264
|
|
|
160
265
|
if include_attributes:
|
|
161
266
|
result["attributes"] = match["attributes"]
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
"device_class",
|
|
169
|
-
"icon",
|
|
170
|
-
"area_id",
|
|
171
|
-
]:
|
|
172
|
-
if key in attrs:
|
|
173
|
-
essential_attrs[key] = attrs[key]
|
|
174
|
-
result["essential_attributes"] = essential_attrs
|
|
267
|
+
# No ``essential_attributes`` fallback — the other four
|
|
268
|
+
# search-type branches (exact_match, area_only,
|
|
269
|
+
# area_filtered_query, domain_listing) never emit it, so
|
|
270
|
+
# surfacing it only from fuzzy_search was a shape
|
|
271
|
+
# asymmetry. Callers needing full state should follow
|
|
272
|
+
# up with ``ha_get_state``.
|
|
175
273
|
|
|
176
274
|
results.append(result)
|
|
177
275
|
|
|
@@ -213,18 +311,25 @@ class SmartSearchTools:
|
|
|
213
311
|
)
|
|
214
312
|
|
|
215
313
|
async def get_entities_by_area(
|
|
216
|
-
self,
|
|
314
|
+
self,
|
|
315
|
+
area_query: str,
|
|
316
|
+
group_by_domain: bool = True,
|
|
317
|
+
include_hidden: bool = True,
|
|
217
318
|
) -> dict[str, Any]:
|
|
218
319
|
"""
|
|
219
320
|
Get entities grouped by area/room using the HA registries for accurate area resolution.
|
|
220
321
|
|
|
221
322
|
Uses entity registry, device registry, and area registry to determine
|
|
222
323
|
which area each entity belongs to. Fuzzy matches the query against
|
|
223
|
-
area names
|
|
324
|
+
area names, IDs, and area-registry aliases to find the target area(s).
|
|
224
325
|
|
|
225
326
|
Args:
|
|
226
|
-
area_query: Area/room name to search for
|
|
327
|
+
area_query: Area/room name (or alias) to search for
|
|
227
328
|
group_by_domain: Whether to group results by domain within each area
|
|
329
|
+
include_hidden: When True (default), entities with ``hidden_by``
|
|
330
|
+
set in the entity registry are still grouped under their
|
|
331
|
+
area but receive a score penalty when ranked. Pass False
|
|
332
|
+
to filter them out entirely.
|
|
228
333
|
|
|
229
334
|
Returns:
|
|
230
335
|
Dictionary with area-grouped entities
|
|
@@ -260,7 +365,7 @@ class SmartSearchTools:
|
|
|
260
365
|
if area_id:
|
|
261
366
|
area_registry[area_id] = area
|
|
262
367
|
|
|
263
|
-
# Parse entity registry: entity_id -> {area_id, device_id}
|
|
368
|
+
# Parse entity registry: entity_id -> {area_id, device_id, hidden_by}
|
|
264
369
|
entity_reg_map: dict[str, dict[str, str | None]] = {}
|
|
265
370
|
if isinstance(results[2], dict) and results[2].get("success"):
|
|
266
371
|
for entry in results[2].get("result", []):
|
|
@@ -269,6 +374,7 @@ class SmartSearchTools:
|
|
|
269
374
|
entity_reg_map[entity_id] = {
|
|
270
375
|
"area_id": entry.get("area_id"),
|
|
271
376
|
"device_id": entry.get("device_id"),
|
|
377
|
+
"hidden_by": entry.get("hidden_by"),
|
|
272
378
|
}
|
|
273
379
|
|
|
274
380
|
# Parse device registry: device_id -> area_id
|
|
@@ -279,27 +385,54 @@ class SmartSearchTools:
|
|
|
279
385
|
if device_id:
|
|
280
386
|
device_area_map[device_id] = device.get("area_id")
|
|
281
387
|
|
|
282
|
-
#
|
|
388
|
+
# Two-pass area resolution. Pass 1 collects exact id / name /
|
|
389
|
+
# alias matches; if any are found, fuzzy aggregation is
|
|
390
|
+
# skipped entirely. This makes ``area_filter`` honor a
|
|
391
|
+
# literal area_id from ``ha_config_list_areas`` — pre-fix a
|
|
392
|
+
# query like ``"bedroom_kids"`` would also fuzzy-match its
|
|
393
|
+
# parent ``"bedroom"`` (partial_ratio=100) and aggregate
|
|
394
|
+
# sibling areas' entities. Aliases (per-area registry, used
|
|
395
|
+
# by HA voice config) mirror the entity-side enrichment in
|
|
396
|
+
# smart_entity_search.
|
|
283
397
|
area_query_lower = area_query.lower().strip()
|
|
284
|
-
|
|
398
|
+
exact_area_ids: set[str] = set()
|
|
399
|
+
fuzzy_area_ids: set[str] = set()
|
|
285
400
|
|
|
286
401
|
for area_id, area_info in area_registry.items():
|
|
287
402
|
area_name = area_info.get("name", "")
|
|
288
|
-
|
|
403
|
+
area_aliases = area_info.get("aliases", []) or []
|
|
404
|
+
# Exact match on area_id, name, or any alias (case-insensitive)
|
|
289
405
|
if (
|
|
290
406
|
area_query_lower == area_id.lower()
|
|
291
407
|
or area_query_lower == area_name.lower()
|
|
408
|
+
or any(
|
|
409
|
+
area_query_lower == a.lower()
|
|
410
|
+
for a in area_aliases
|
|
411
|
+
if isinstance(a, str)
|
|
412
|
+
)
|
|
292
413
|
):
|
|
293
|
-
|
|
414
|
+
exact_area_ids.add(area_id)
|
|
294
415
|
continue
|
|
295
|
-
# Fuzzy match on area name
|
|
416
|
+
# Fuzzy match on area name, id, or any alias
|
|
296
417
|
name_score = calculate_partial_ratio(
|
|
297
418
|
area_query_lower, area_name.lower()
|
|
298
419
|
)
|
|
299
420
|
id_score = calculate_partial_ratio(area_query_lower, area_id.lower())
|
|
300
|
-
|
|
421
|
+
alias_score = max(
|
|
422
|
+
(
|
|
423
|
+
calculate_partial_ratio(area_query_lower, a.lower())
|
|
424
|
+
for a in area_aliases
|
|
425
|
+
if isinstance(a, str)
|
|
426
|
+
),
|
|
427
|
+
default=0,
|
|
428
|
+
)
|
|
429
|
+
best_score = max(name_score, id_score, alias_score)
|
|
301
430
|
if best_score >= 80:
|
|
302
|
-
|
|
431
|
+
fuzzy_area_ids.add(area_id)
|
|
432
|
+
|
|
433
|
+
# Exact matches win — fuzzy aggregation only runs when no
|
|
434
|
+
# area_query_lower is itself an area_id / name / alias.
|
|
435
|
+
matched_area_ids = exact_area_ids or fuzzy_area_ids
|
|
303
436
|
|
|
304
437
|
if not matched_area_ids:
|
|
305
438
|
return {
|
|
@@ -313,10 +446,19 @@ class SmartSearchTools:
|
|
|
313
446
|
],
|
|
314
447
|
}
|
|
315
448
|
|
|
316
|
-
# Build entity_id -> resolved area_id mapping
|
|
317
|
-
# Priority: entity direct area_id > device area_id
|
|
449
|
+
# Build entity_id -> resolved area_id mapping.
|
|
450
|
+
# Priority: entity direct area_id > device area_id.
|
|
451
|
+
# Hidden entities are filtered only when include_hidden is
|
|
452
|
+
# False; otherwise they pass through and downstream applies
|
|
453
|
+
# the score penalty so they sort below visible matches.
|
|
318
454
|
entity_area_resolved: dict[str, str] = {}
|
|
455
|
+
hidden_entity_ids: set[str] = set()
|
|
319
456
|
for entity_id, reg_info in entity_reg_map.items():
|
|
457
|
+
is_hidden = reg_info.get("hidden_by") is not None
|
|
458
|
+
if is_hidden and not include_hidden:
|
|
459
|
+
continue
|
|
460
|
+
if is_hidden:
|
|
461
|
+
hidden_entity_ids.add(entity_id)
|
|
320
462
|
area_id = reg_info.get("area_id")
|
|
321
463
|
device_id = reg_info.get("device_id")
|
|
322
464
|
if not area_id and device_id:
|
|
@@ -331,7 +473,12 @@ class SmartSearchTools:
|
|
|
331
473
|
if eid:
|
|
332
474
|
state_map[eid] = entity
|
|
333
475
|
|
|
334
|
-
# Collect entities belonging to matched areas
|
|
476
|
+
# Collect entities belonging to matched areas. Alias data is
|
|
477
|
+
# NOT enriched here — exposing private `_aliases` on a public
|
|
478
|
+
# method would leak through any caller that round-trips this
|
|
479
|
+
# response (e.g. server.py:get_entities_by_area). The
|
|
480
|
+
# area+query consumer in tools_search.py fetches aliases on
|
|
481
|
+
# its own when needed.
|
|
335
482
|
formatted_areas: dict[str, dict[str, Any]] = {}
|
|
336
483
|
total_entities = 0
|
|
337
484
|
|
|
@@ -360,6 +507,9 @@ class SmartSearchTools:
|
|
|
360
507
|
state_info = state_map.get(entity_id, {})
|
|
361
508
|
if domain not in domains:
|
|
362
509
|
domains[domain] = []
|
|
510
|
+
# Carry ``_hidden_by`` as a sentinel ("hidden" or
|
|
511
|
+
# None) so downstream branches can apply the
|
|
512
|
+
# score penalty without a second registry lookup.
|
|
363
513
|
domains[domain].append(
|
|
364
514
|
{
|
|
365
515
|
"entity_id": entity_id,
|
|
@@ -367,6 +517,11 @@ class SmartSearchTools:
|
|
|
367
517
|
"friendly_name", entity_id
|
|
368
518
|
),
|
|
369
519
|
"state": state_info.get("state", "unknown"),
|
|
520
|
+
"_hidden_by": (
|
|
521
|
+
"hidden"
|
|
522
|
+
if entity_id in hidden_entity_ids
|
|
523
|
+
else None
|
|
524
|
+
),
|
|
370
525
|
}
|
|
371
526
|
)
|
|
372
527
|
area_data["entities"] = domains
|
|
@@ -381,6 +536,11 @@ class SmartSearchTools:
|
|
|
381
536
|
.get("friendly_name", entity_id),
|
|
382
537
|
"domain": entity_id.split(".")[0],
|
|
383
538
|
"state": state_info.get("state", "unknown"),
|
|
539
|
+
"_hidden_by": (
|
|
540
|
+
"hidden"
|
|
541
|
+
if entity_id in hidden_entity_ids
|
|
542
|
+
else None
|
|
543
|
+
),
|
|
384
544
|
}
|
|
385
545
|
for entity_id in area_entities
|
|
386
546
|
]
|