ha-mcp-dev 7.4.1.dev470__tar.gz → 7.4.1.dev472__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 (110) hide show
  1. {ha_mcp_dev-7.4.1.dev470/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev472}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/client/rest_client.py +10 -11
  4. ha_mcp_dev-7.4.1.dev472/src/ha_mcp/client/supervisor_client.py +88 -0
  5. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/server.py +13 -6
  6. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/settings_ui.py +5 -8
  7. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/smart_search.py +192 -32
  8. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_bug_report.py +4 -8
  9. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_search.py +386 -152
  10. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/util_helpers.py +47 -0
  11. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/utils/fuzzy_search.py +162 -19
  12. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  13. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp_dev.egg-info/SOURCES.txt +1 -0
  14. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/LICENSE +0 -0
  15. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/MANIFEST.in +0 -0
  16. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/README.md +0 -0
  17. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/setup.cfg +0 -0
  18. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/__init__.py +0 -0
  19. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/__main__.py +0 -0
  20. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/_pypi_marker +0 -0
  21. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/_version.py +0 -0
  22. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/auth/__init__.py +0 -0
  23. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/auth/consent_form.py +0 -0
  24. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/auth/provider.py +0 -0
  25. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/client/__init__.py +0 -0
  26. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/client/websocket_client.py +0 -0
  27. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/client/websocket_listener.py +0 -0
  28. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/config.py +0 -0
  29. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/errors.py +0 -0
  30. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/py.typed +0 -0
  31. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  32. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  33. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  34. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  35. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  36. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  37. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  38. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  39. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  40. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  41. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  42. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  43. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  44. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  45. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  46. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  47. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  48. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  49. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  50. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  51. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/smoke_test.py +0 -0
  52. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/__init__.py +0 -0
  53. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/backup.py +0 -0
  54. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  55. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/device_control.py +0 -0
  56. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/enhanced.py +0 -0
  57. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/helpers.py +0 -0
  58. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/reference_validator.py +0 -0
  59. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/registry.py +0 -0
  60. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_addons.py +0 -0
  61. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_areas.py +0 -0
  62. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  63. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_calendar.py +0 -0
  64. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_camera.py +0 -0
  65. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_categories.py +0 -0
  66. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_code.py +0 -0
  67. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  68. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  69. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  70. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  71. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
  72. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  73. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_energy.py +0 -0
  74. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_entities.py +0 -0
  75. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  76. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_groups.py +0 -0
  77. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_hacs.py +0 -0
  78. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_history.py +0 -0
  79. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_integrations.py +0 -0
  80. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_labels.py +0 -0
  81. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  82. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_registry.py +0 -0
  83. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_resources.py +0 -0
  84. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_service.py +0 -0
  85. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_services.py +0 -0
  86. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_system.py +0 -0
  87. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_todo.py +0 -0
  88. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_traces.py +0 -0
  89. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_updates.py +0 -0
  90. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_utility.py +0 -0
  91. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  92. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  93. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/tools/tools_zones.py +0 -0
  94. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/transforms/__init__.py +0 -0
  95. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/transforms/categorized_search.py +0 -0
  96. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/utils/__init__.py +0 -0
  97. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/utils/config_hash.py +0 -0
  98. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/utils/data_paths.py +0 -0
  99. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/utils/domain_handlers.py +0 -0
  100. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  101. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/utils/operation_manager.py +0 -0
  102. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/utils/python_sandbox.py +0 -0
  103. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp/utils/usage_logger.py +0 -0
  104. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  105. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  106. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  107. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  108. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/tests/__init__.py +0 -0
  109. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/tests/test_constants.py +0 -0
  110. {ha_mcp_dev-7.4.1.dev470 → ha_mcp_dev-7.4.1.dev472}/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.dev470
3
+ Version: 7.4.1.dev472
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.dev470"
7
+ version = "7.4.1.dev472"
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"
@@ -13,6 +13,7 @@ import httpx
13
13
 
14
14
  from .._version import get_supervisor_base_url, is_running_in_addon
15
15
  from ..config import get_global_settings
16
+ from .supervisor_client import make_supervisor_httpx_client
16
17
 
17
18
 
18
19
  def _is_ssl_error(exc: BaseException) -> bool:
@@ -555,23 +556,21 @@ class HomeAssistantClient:
555
556
  "(addon-mode gate fired but SUPERVISOR_TOKEN env var not set)"
556
557
  )
557
558
 
558
- url = f"{get_supervisor_base_url()}/{path}/logs"
559
- logger.debug("Fetching %s via Supervisor direct", url)
559
+ relative_path = f"/{path}/logs"
560
+ logger.debug(
561
+ "Fetching %s%s via Supervisor direct",
562
+ get_supervisor_base_url(),
563
+ relative_path,
564
+ )
560
565
 
561
566
  try:
562
- async with httpx.AsyncClient(
567
+ async with make_supervisor_httpx_client(
563
568
  timeout=httpx.Timeout(self.timeout),
564
- # `verify` is a no-op for plain http://supervisor, but kept
565
- # for symmetry with the other two direct-Supervisor httpx
566
- # clients (#1128 establishes the 3-site convention).
567
569
  verify=self.verify_ssl,
568
570
  ) as client:
569
571
  response = await client.get(
570
- url,
571
- headers={
572
- "Authorization": f"Bearer {token}",
573
- "Accept": "text/plain",
574
- },
572
+ relative_path,
573
+ headers={"Accept": "text/plain"},
575
574
  )
576
575
  except httpx.TimeoutException as e:
577
576
  raise HomeAssistantConnectionError(
@@ -0,0 +1,88 @@
1
+ """Shared factory for direct-Supervisor httpx clients.
2
+
3
+ Three call sites in the codebase talk directly to the Home Assistant
4
+ Supervisor REST API at ``http://supervisor`` rather than through
5
+ ``HomeAssistantClient.httpx_client`` (which is bound to HA Core, not the
6
+ Supervisor — different base URL, different token, different role gate):
7
+
8
+ - :meth:`ha_mcp.client.rest_client.HomeAssistantClient._supervisor_logs_get`
9
+ — fetches addon and system-service logs
10
+ - :func:`ha_mcp.tools.tools_bug_report._fetch_addon_logs` — bundles ha-mcp's
11
+ own addon logs into a bug-report payload
12
+ - :func:`ha_mcp.settings_ui._restart_addon` — POSTs ``/addons/self/restart``
13
+ from the settings UI
14
+
15
+ All three share the same boilerplate (base URL, ``Authorization: Bearer
16
+ ${SUPERVISOR_TOKEN}`` header), so this module supplies a single factory and
17
+ keeps the three sites consistent.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import ssl
24
+
25
+ import httpx
26
+
27
+ from .._version import get_supervisor_base_url
28
+
29
+ __all__ = ["make_supervisor_httpx_client"]
30
+
31
+
32
+ def make_supervisor_httpx_client(
33
+ *,
34
+ timeout: float | httpx.Timeout,
35
+ verify: bool | str | ssl.SSLContext,
36
+ ) -> httpx.AsyncClient:
37
+ """Construct an ``httpx.AsyncClient`` pre-configured for the Supervisor REST API.
38
+
39
+ Args:
40
+ timeout: Per-request timeout. Accepts either a plain ``float``
41
+ (seconds, applied to all phases) or a full :class:`httpx.Timeout`
42
+ for finer-grained control.
43
+ verify: TLS verify policy. A no-op for the default
44
+ ``http://supervisor`` base URL (plain HTTP — no TLS to verify),
45
+ but kept as a parameter because :func:`get_supervisor_base_url`
46
+ honours ``SUPERVISOR_BASE_URL`` env-var overrides that may be
47
+ HTTPS in non-add-on test rigs. The full httpx ``verify`` surface
48
+ (``bool``, CA-bundle path, or :class:`ssl.SSLContext`) is
49
+ accepted and forwarded verbatim.
50
+
51
+ Returns:
52
+ A new :class:`httpx.AsyncClient` bound to the Supervisor base URL
53
+ with ``Authorization: Bearer ${SUPERVISOR_TOKEN}`` preset. Callers
54
+ pass relative paths (``/addons/self/logs``) to ``client.get/post``;
55
+ ``base_url`` joins them onto the Supervisor host.
56
+
57
+ Raises:
58
+ RuntimeError: ``SUPERVISOR_TOKEN`` is unset or empty in the
59
+ environment. Each call site has its own absent-token policy
60
+ (a rich :class:`HomeAssistantAuthError`, a silent ``""``
61
+ return, or a 400 ``JSONResponse``) that does not share a
62
+ common shape, so the factory cannot translate. Detecting the
63
+ absence at construction time prevents a malformed
64
+ ``Authorization: Bearer `` header from being read as a token
65
+ rejection by Supervisor, which would mask the missing-env-var
66
+ root cause.
67
+
68
+ Note:
69
+ ``SUPERVISOR_TOKEN`` is read from env at construction time and
70
+ baked into the constructed client's ``Authorization`` header.
71
+ Reusing a single client across token rotations would not pick up
72
+ the new value — short-lived ``async with`` callers are unaffected,
73
+ but a future long-lived caller would need to discard and re-create.
74
+ """
75
+ token = os.environ.get("SUPERVISOR_TOKEN", "")
76
+ if not token:
77
+ raise RuntimeError(
78
+ "SUPERVISOR_TOKEN is not set; "
79
+ "make_supervisor_httpx_client cannot construct an "
80
+ "authenticated client. Callers must verify the token is "
81
+ "present before invoking the factory."
82
+ )
83
+ return httpx.AsyncClient(
84
+ base_url=get_supervisor_base_url(),
85
+ timeout=timeout,
86
+ verify=verify,
87
+ headers={"Authorization": f"Bearer {token}"},
88
+ )
@@ -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."""
@@ -19,7 +19,8 @@ import httpx
19
19
  from starlette.requests import Request
20
20
  from starlette.responses import HTMLResponse, JSONResponse
21
21
 
22
- from ._version import get_supervisor_base_url, is_running_in_addon
22
+ from ._version import is_running_in_addon
23
+ from .client.supervisor_client import make_supervisor_httpx_client
23
24
  from .errors import ErrorCode, create_error_response
24
25
  from .transforms import DEFAULT_PINNED_TOOLS
25
26
  from .utils.data_paths import get_data_dir
@@ -893,8 +894,7 @@ def register_settings_routes(
893
894
  )
894
895
 
895
896
  async def _restart_addon(_: Request) -> JSONResponse:
896
- token = os.environ.get("SUPERVISOR_TOKEN")
897
- if not token:
897
+ if not os.environ.get("SUPERVISOR_TOKEN"):
898
898
  return JSONResponse(
899
899
  create_error_response(
900
900
  ErrorCode.CONFIG_VALIDATION_FAILED,
@@ -906,13 +906,10 @@ def register_settings_routes(
906
906
  # Short timeout — the supervisor kills our process during restart so
907
907
  # the connection will drop. A connection drop is actually success.
908
908
  try:
909
- async with httpx.AsyncClient(
909
+ async with make_supervisor_httpx_client(
910
910
  timeout=5.0, verify=server.settings.verify_ssl
911
911
  ) as client:
912
- resp = await client.post(
913
- f"{get_supervisor_base_url()}/addons/self/restart",
914
- headers={"Authorization": f"Bearer {token}"},
915
- )
912
+ resp = await client.post("/addons/self/restart")
916
913
  except (httpx.ReadError, httpx.RemoteProtocolError):
917
914
  # Connection dropped mid-request — restart is happening.
918
915
  # `ConnectError` is deliberately NOT in this tuple: it fires
@@ -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
  ]
@@ -20,7 +20,7 @@ from pydantic import Field
20
20
 
21
21
  from ha_mcp import __version__
22
22
 
23
- from .._version import get_supervisor_base_url
23
+ from ..client.supervisor_client import make_supervisor_httpx_client
24
24
  from ..config import Settings, get_global_settings
25
25
  from ..utils.usage_logger import (
26
26
  AVG_LOG_ENTRIES_PER_TOOL,
@@ -349,18 +349,14 @@ async def _fetch_addon_logs() -> str:
349
349
  """
350
350
  # Redundant with the caller's `install_method == "addon"` gate, but kept
351
351
  # as a defensive guard for any direct callers added later.
352
- token = os.environ.get("SUPERVISOR_TOKEN", "")
353
- if not token:
352
+ if not os.environ.get("SUPERVISOR_TOKEN"):
354
353
  return ""
355
354
 
356
355
  try:
357
- async with httpx.AsyncClient(
356
+ async with make_supervisor_httpx_client(
358
357
  timeout=10.0, verify=get_global_settings().verify_ssl
359
358
  ) as http_client:
360
- resp = await http_client.get(
361
- f"{get_supervisor_base_url()}/addons/self/logs",
362
- headers={"Authorization": f"Bearer {token}"},
363
- )
359
+ resp = await http_client.get("/addons/self/logs")
364
360
  if resp.status_code != 200:
365
361
  logger.info("Addon log fetch returned HTTP %s", resp.status_code)
366
362
  return ""