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.
Files changed (109) hide show
  1. {ha_mcp_dev-7.4.1.dev469/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev471}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/server.py +13 -6
  4. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/smart_search.py +192 -32
  5. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_search.py +386 -152
  6. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/util_helpers.py +47 -0
  7. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/utils/fuzzy_search.py +162 -19
  8. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  9. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/LICENSE +0 -0
  10. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/MANIFEST.in +0 -0
  11. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/README.md +0 -0
  12. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/setup.cfg +0 -0
  13. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/__init__.py +0 -0
  14. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/__main__.py +0 -0
  15. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/_pypi_marker +0 -0
  16. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/_version.py +0 -0
  17. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/auth/__init__.py +0 -0
  18. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/auth/consent_form.py +0 -0
  19. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/auth/provider.py +0 -0
  20. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/client/__init__.py +0 -0
  21. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/client/rest_client.py +0 -0
  22. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/client/websocket_client.py +0 -0
  23. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/client/websocket_listener.py +0 -0
  24. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/config.py +0 -0
  25. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/errors.py +0 -0
  26. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/py.typed +0 -0
  27. {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
  28. {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
  29. {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
  30. {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
  31. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  32. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  33. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  34. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  35. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  36. {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
  37. {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
  38. {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
  39. {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
  40. {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
  41. {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
  42. {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
  43. {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
  44. {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
  45. {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
  46. {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
  47. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/settings_ui.py +0 -0
  48. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/smoke_test.py +0 -0
  49. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/__init__.py +0 -0
  50. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/backup.py +0 -0
  51. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  52. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/device_control.py +0 -0
  53. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/enhanced.py +0 -0
  54. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/helpers.py +0 -0
  55. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/reference_validator.py +0 -0
  56. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/registry.py +0 -0
  57. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_addons.py +0 -0
  58. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_areas.py +0 -0
  59. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  60. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  61. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_calendar.py +0 -0
  62. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_camera.py +0 -0
  63. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_categories.py +0 -0
  64. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_code.py +0 -0
  65. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  66. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  67. {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
  68. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  69. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
  70. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  71. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_energy.py +0 -0
  72. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_entities.py +0 -0
  73. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  74. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_groups.py +0 -0
  75. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_hacs.py +0 -0
  76. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_history.py +0 -0
  77. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_integrations.py +0 -0
  78. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_labels.py +0 -0
  79. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  80. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_registry.py +0 -0
  81. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_resources.py +0 -0
  82. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_service.py +0 -0
  83. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_services.py +0 -0
  84. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_system.py +0 -0
  85. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_todo.py +0 -0
  86. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_traces.py +0 -0
  87. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_updates.py +0 -0
  88. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_utility.py +0 -0
  89. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  90. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  91. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/tools/tools_zones.py +0 -0
  92. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/transforms/__init__.py +0 -0
  93. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/transforms/categorized_search.py +0 -0
  94. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/utils/__init__.py +0 -0
  95. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/utils/config_hash.py +0 -0
  96. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/utils/data_paths.py +0 -0
  97. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/utils/domain_handlers.py +0 -0
  98. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  99. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/utils/operation_manager.py +0 -0
  100. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/utils/python_sandbox.py +0 -0
  101. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp/utils/usage_logger.py +0 -0
  102. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  103. {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
  104. {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
  105. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  106. {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
  107. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/tests/__init__.py +0 -0
  108. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/tests/test_constants.py +0 -0
  109. {ha_mcp_dev-7.4.1.dev469 → ha_mcp_dev-7.4.1.dev471}/tests/test_env_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.4.1.dev469
3
+ Version: 7.4.1.dev471
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT
@@ -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.dev469"
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
- return cast(
935
- dict[str, Any],
936
- await self.smart_tools.get_entities_by_area(
937
- area_query=area_name, group_by_domain=True
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
- Advanced entity search with fuzzy matching and typo tolerance.
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
- # Get all entities
132
- entities = await self.client.get_states()
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
- # Filter by domain BEFORE fuzzy search if domain_filter provided
135
- # This ensures fuzzy search only looks at entities in the target domain
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
- else:
163
- # Include only essential attributes
164
- attrs = match["attributes"]
165
- essential_attrs = {}
166
- for key in [
167
- "unit_of_measurement",
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, area_query: str, group_by_domain: bool = True
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/IDs to find the target area(s).
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
- # Fuzzy match area_query against known area names and IDs
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
- matched_area_ids: set[str] = set()
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
- # Exact match on area_id or name (case-insensitive)
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
- matched_area_ids.add(area_id)
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
- best_score = max(name_score, id_score)
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
- matched_area_ids.add(area_id)
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
  ]