ha-mcp-dev 7.5.0.dev564__tar.gz → 7.5.0.dev566__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 (113) hide show
  1. {ha_mcp_dev-7.5.0.dev564/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.5.0.dev566}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/client/rest_client.py +14 -0
  4. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/client/websocket_client.py +52 -13
  5. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_areas.py +197 -69
  6. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_history.py +267 -147
  7. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_search.py +561 -44
  8. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_services.py +79 -5
  9. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/util_helpers.py +531 -90
  10. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  11. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/LICENSE +0 -0
  12. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/MANIFEST.in +0 -0
  13. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/README.md +0 -0
  14. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/setup.cfg +0 -0
  15. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/__init__.py +0 -0
  16. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/__main__.py +0 -0
  17. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/_pypi_marker +0 -0
  18. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/_version.py +0 -0
  19. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/auth/__init__.py +0 -0
  20. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/auth/consent_form.py +0 -0
  21. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/auth/provider.py +0 -0
  22. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/client/__init__.py +0 -0
  23. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/client/supervisor_client.py +0 -0
  24. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/client/websocket_listener.py +0 -0
  25. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/config.py +0 -0
  26. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/errors.py +0 -0
  27. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/py.typed +0 -0
  28. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  29. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  30. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  31. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  32. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
  33. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  34. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  35. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  36. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  37. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  38. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  39. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  40. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  41. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  42. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  43. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  44. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  45. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  46. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  47. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  48. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  49. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
  50. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/server.py +0 -0
  51. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/settings_ui.py +0 -0
  52. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/smoke_test.py +0 -0
  53. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/__init__.py +0 -0
  54. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/backup.py +0 -0
  55. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  56. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/device_control.py +0 -0
  57. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/enhanced.py +0 -0
  58. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/helpers.py +0 -0
  59. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/reference_validator.py +0 -0
  60. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/registry.py +0 -0
  61. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/smart_search.py +0 -0
  62. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_addons.py +0 -0
  63. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  64. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  65. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_calendar.py +0 -0
  66. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_camera.py +0 -0
  67. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_categories.py +0 -0
  68. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_code.py +0 -0
  69. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  70. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  71. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  72. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  73. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
  74. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  75. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_energy.py +0 -0
  76. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_entities.py +0 -0
  77. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  78. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_groups.py +0 -0
  79. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_hacs.py +0 -0
  80. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_integrations.py +0 -0
  81. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_labels.py +0 -0
  82. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  83. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_registry.py +0 -0
  84. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_resources.py +0 -0
  85. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_service.py +0 -0
  86. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_system.py +0 -0
  87. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_todo.py +0 -0
  88. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_traces.py +0 -0
  89. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_updates.py +0 -0
  90. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_utility.py +0 -0
  91. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  92. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  93. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/tools/tools_zones.py +0 -0
  94. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/transforms/__init__.py +0 -0
  95. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/transforms/categorized_search.py +0 -0
  96. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
  97. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/utils/__init__.py +0 -0
  98. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/utils/config_hash.py +0 -0
  99. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/utils/data_paths.py +0 -0
  100. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/utils/domain_handlers.py +0 -0
  101. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  102. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  103. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/utils/operation_manager.py +0 -0
  104. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/utils/python_sandbox.py +0 -0
  105. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp/utils/usage_logger.py +0 -0
  106. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  107. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  108. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  109. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  110. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  111. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/tests/__init__.py +0 -0
  112. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/tests/test_constants.py +0 -0
  113. {ha_mcp_dev-7.5.0.dev564 → ha_mcp_dev-7.5.0.dev566}/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.5.0.dev564
3
+ Version: 7.5.0.dev566
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.5.0.dev564"
7
+ version = "7.5.0.dev566"
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"
@@ -82,6 +82,20 @@ class HomeAssistantCommandError(HomeAssistantError):
82
82
  """
83
83
 
84
84
 
85
+ class HomeAssistantCommandTimeout(HomeAssistantError):
86
+ """WebSocket ``send_command`` timed out waiting for HA's response.
87
+
88
+ Sibling of ``HomeAssistantCommandError`` (not a subclass) so existing
89
+ ``except HomeAssistantCommandError`` sites — including the match
90
+ dispatch in ``helpers._classify_exception`` — keep their original
91
+ semantics. Callers that specifically want to handle our 30s WS
92
+ round-trip timeout (e.g. short-lived waiter cleanup that should
93
+ swallow a timeout instead of masking the real wait result) catch
94
+ this type directly. Replaces a bare ``Exception("Command timeout")``
95
+ string-match pattern (#1382 Patch76 review).
96
+ """
97
+
98
+
85
99
  class HomeAssistantClient:
86
100
  """Authenticated HTTP client for Home Assistant API."""
87
101
 
@@ -23,6 +23,7 @@ import websockets
23
23
  from ..config import get_global_settings
24
24
  from .rest_client import (
25
25
  HomeAssistantCommandError,
26
+ HomeAssistantCommandTimeout,
26
27
  HomeAssistantConnectionError,
27
28
  _is_ssl_error,
28
29
  )
@@ -58,6 +59,7 @@ class WebSocketConnectionState:
58
59
  )
59
60
  self._pending_requests[message_id] = future
60
61
  return future
62
+
61
63
  def resolve_pending_request(
62
64
  self, message_id: int
63
65
  ) -> asyncio.Future[dict[str, Any]] | None:
@@ -262,7 +264,9 @@ class HomeAssistantWebSocketClient:
262
264
  message_type="auth_required", timeout=5
263
265
  )
264
266
  if not auth_msg:
265
- raise HomeAssistantConnectionError("Did not receive auth_required message")
267
+ raise HomeAssistantConnectionError(
268
+ "Did not receive auth_required message"
269
+ )
266
270
 
267
271
  # Send authentication
268
272
  await self._send_auth()
@@ -511,7 +515,7 @@ class HomeAssistantWebSocketClient:
511
515
 
512
516
  except TimeoutError as e:
513
517
  self.cancel_pending_response(message_id)
514
- raise Exception("Command timeout") from e
518
+ raise HomeAssistantCommandTimeout("Command timeout") from e
515
519
  except Exception:
516
520
  self.cancel_pending_response(message_id)
517
521
  raise
@@ -572,9 +576,7 @@ class HomeAssistantWebSocketClient:
572
576
  raise HomeAssistantCommandError(f"Command failed: {error_msg}")
573
577
 
574
578
  try:
575
- event_response = await asyncio.wait_for(
576
- event_future, timeout=wait_timeout
577
- )
579
+ event_response = await asyncio.wait_for(event_future, timeout=wait_timeout)
578
580
  except TimeoutError:
579
581
  self.cancel_event_response(message_id)
580
582
  raise
@@ -639,13 +641,50 @@ class HomeAssistantWebSocketClient:
639
641
 
640
642
  error = response.get("error", {})
641
643
  error_msg = (
642
- error.get("message", str(error))
643
- if isinstance(error, dict)
644
- else str(error)
645
- )
646
- raise HomeAssistantCommandError(
647
- f"subscribe_events failed: {error_msg}"
644
+ error.get("message", str(error)) if isinstance(error, dict) else str(error)
648
645
  )
646
+ raise HomeAssistantCommandError(f"subscribe_events failed: {error_msg}")
647
+
648
+ async def unsubscribe_events(self, subscription_id: int) -> None:
649
+ """Release a subscription previously returned by ``subscribe_events``.
650
+
651
+ Used by short-lived waiters (``util_helpers.wait_for_*``) that need
652
+ to drop the subscription as soon as their event arrives so the
653
+ shared socket doesn't accumulate stale ``state_changed`` listeners.
654
+
655
+ Exception policy (narrow, distinct log levels — Gemini #1382):
656
+
657
+ - Transport-level loss (``OSError``): subscription is implicitly
658
+ gone with the connection. Logged at ``debug`` so HA-mid-restart
659
+ cleanup doesn't spam warnings.
660
+ - HA-side rejection (``HomeAssistantCommandError``, e.g. "Subscription
661
+ not found" after a server-side reset): unexpected during normal
662
+ cleanup. Logged at ``warning`` so a real subscription leak is
663
+ discoverable.
664
+ - Everything else: propagates to the caller's ``finally`` so a
665
+ programming bug (TypeError, AttributeError) fails loudly instead
666
+ of being buried under a broad ``except``.
667
+ """
668
+ if not self._state.is_ready:
669
+ logger.debug(
670
+ "unsubscribe_events(%s) skipped: WebSocket not ready",
671
+ subscription_id,
672
+ )
673
+ return
674
+ try:
675
+ await self.send_command("unsubscribe_events", subscription=subscription_id)
676
+ except OSError as e:
677
+ logger.debug(
678
+ "unsubscribe_events(%s): transport lost during cleanup: %s",
679
+ subscription_id,
680
+ e,
681
+ )
682
+ except HomeAssistantCommandError as e:
683
+ logger.warning(
684
+ "unsubscribe_events(%s) rejected by HA: %s",
685
+ subscription_id,
686
+ e,
687
+ )
649
688
 
650
689
  def add_event_handler(
651
690
  self,
@@ -721,7 +760,6 @@ class HomeAssistantWebSocketClient:
721
760
  return self._state.is_ready
722
761
 
723
762
 
724
-
725
763
  MAX_POOL_SIZE = 50
726
764
 
727
765
 
@@ -755,7 +793,8 @@ class WebSocketManager:
755
793
  def configure(
756
794
  self,
757
795
  *,
758
- client_factory: Callable[[str, str], HomeAssistantWebSocketClient] | None = None,
796
+ client_factory: Callable[[str, str], HomeAssistantWebSocketClient]
797
+ | None = None,
759
798
  ) -> None:
760
799
  """Configure the manager with injectable dependencies."""
761
800
  if client_factory is not None:
@@ -12,7 +12,7 @@ from fastmcp.exceptions import ToolError
12
12
  from fastmcp.tools import tool
13
13
  from pydantic import Field
14
14
 
15
- from ..errors import ErrorCode, create_error_response
15
+ from ..errors import ErrorCode, create_error_response, create_validation_error
16
16
  from .helpers import (
17
17
  exception_to_structured_error,
18
18
  log_tool_usage,
@@ -20,7 +20,12 @@ from .helpers import (
20
20
  register_tool_methods,
21
21
  validate_identifier_not_empty,
22
22
  )
23
- from .util_helpers import parse_string_list_param
23
+ from .util_helpers import (
24
+ parse_string_list_param,
25
+ project_fields,
26
+ project_records,
27
+ result_fields_warning,
28
+ )
24
29
 
25
30
  logger = logging.getLogger(__name__)
26
31
 
@@ -130,15 +135,65 @@ class AreaTools:
130
135
  @tool(
131
136
  name="ha_config_list_areas",
132
137
  tags={"Areas & Floors"},
133
- annotations={"idempotentHint": True, "readOnlyHint": True, "title": "List Areas"},
138
+ annotations={
139
+ "idempotentHint": True,
140
+ "readOnlyHint": True,
141
+ "title": "List Areas",
142
+ },
134
143
  )
135
144
  @log_tool_usage
136
- async def ha_config_list_areas(self) -> dict[str, Any]:
145
+ async def ha_config_list_areas(
146
+ self,
147
+ fields: Annotated[
148
+ str | list[str] | None,
149
+ Field(
150
+ default=None,
151
+ description=(
152
+ "Return only the specified top-level response keys to reduce "
153
+ 'response size (e.g. ["areas"]). '
154
+ "None = full response (default). "
155
+ "Available keys: success, count, areas, message."
156
+ ),
157
+ ),
158
+ ] = None,
159
+ area_fields: Annotated[
160
+ str | list[str] | None,
161
+ Field(
162
+ default=None,
163
+ description=(
164
+ "Project each area record to only the specified keys. "
165
+ 'E.g. ["area_id", "name"] returns slim area records. '
166
+ "None = full records (default). Unknown keys yield empty records. "
167
+ "Available keys: area_id, name, icon, floor_id, aliases, picture, labels."
168
+ ),
169
+ ),
170
+ ] = None,
171
+ ) -> dict[str, Any]:
137
172
  """
138
173
  List all Home Assistant areas (rooms).
139
174
 
140
175
  Returns area ID, name, icon, floor assignment, aliases, and picture URL.
141
176
  """
177
+ parsed_fields: list[str] | None = None
178
+ if fields is not None:
179
+ try:
180
+ parsed_fields = parse_string_list_param(
181
+ fields, "fields", allow_csv=True
182
+ )
183
+ except ValueError as exc:
184
+ raise_tool_error(create_validation_error(str(exc), parameter="fields"))
185
+ parsed_area_fields: list[str] | None = None
186
+ if area_fields is not None:
187
+ try:
188
+ parsed_area_fields = parse_string_list_param(
189
+ area_fields, "area_fields", allow_csv=True
190
+ )
191
+ if parsed_area_fields is not None and len(parsed_area_fields) == 0:
192
+ raise ValueError("area_fields must contain at least one key")
193
+ except ValueError as exc:
194
+ raise_tool_error(
195
+ create_validation_error(str(exc), parameter="area_fields")
196
+ )
142
197
  try:
143
198
  message: dict[str, Any] = {
144
199
  "type": "config/area_registry/list",
@@ -148,26 +203,42 @@ class AreaTools:
148
203
 
149
204
  if result.get("success"):
150
205
  areas = result.get("result", [])
151
- return {
206
+ _orig_areas = areas
207
+ if parsed_area_fields is not None:
208
+ areas = project_records(areas, parsed_area_fields)
209
+ response: dict[str, Any] = {
152
210
  "success": True,
153
211
  "count": len(areas),
154
212
  "areas": areas,
155
213
  "message": f"Found {len(areas)} area(s)",
156
214
  }
215
+ if parsed_area_fields is not None:
216
+ _warn = result_fields_warning(
217
+ _orig_areas, areas, parsed_area_fields, param_name="area_fields"
218
+ )
219
+ if _warn:
220
+ response.setdefault("warnings", []).append(_warn)
221
+ return project_fields(response, parsed_fields)
157
222
  else:
158
- raise_tool_error(create_error_response(
159
- ErrorCode.SERVICE_CALL_FAILED,
160
- result.get("error", "Failed to list areas"),
161
- ))
223
+ raise_tool_error(
224
+ create_error_response(
225
+ ErrorCode.SERVICE_CALL_FAILED,
226
+ result.get("error", "Failed to list areas"),
227
+ )
228
+ )
162
229
 
163
230
  except ToolError:
164
231
  raise
165
232
  except Exception as e:
166
233
  logger.error(f"Error listing areas: {e}")
167
- exception_to_structured_error(e, context={"operation": "list_areas"}, suggestions=[
168
- "Check Home Assistant connection",
169
- "Verify WebSocket connection is active",
170
- ])
234
+ exception_to_structured_error(
235
+ e,
236
+ context={"operation": "list_areas"},
237
+ suggestions=[
238
+ "Check Home Assistant connection",
239
+ "Verify WebSocket connection is active",
240
+ ],
241
+ )
171
242
 
172
243
  # ============================================================
173
244
  # FLOOR TOOLS
@@ -176,7 +247,11 @@ class AreaTools:
176
247
  @tool(
177
248
  name="ha_config_list_floors",
178
249
  tags={"Areas & Floors"},
179
- annotations={"idempotentHint": True, "readOnlyHint": True, "title": "List Floors"},
250
+ annotations={
251
+ "idempotentHint": True,
252
+ "readOnlyHint": True,
253
+ "title": "List Floors",
254
+ },
180
255
  )
181
256
  @log_tool_usage
182
257
  async def ha_config_list_floors(self) -> dict[str, Any]:
@@ -201,24 +276,34 @@ class AreaTools:
201
276
  "message": f"Found {len(floors)} floor(s)",
202
277
  }
203
278
  else:
204
- raise_tool_error(create_error_response(
205
- ErrorCode.SERVICE_CALL_FAILED,
206
- result.get("error", "Failed to list floors"),
207
- ))
279
+ raise_tool_error(
280
+ create_error_response(
281
+ ErrorCode.SERVICE_CALL_FAILED,
282
+ result.get("error", "Failed to list floors"),
283
+ )
284
+ )
208
285
 
209
286
  except ToolError:
210
287
  raise
211
288
  except Exception as e:
212
289
  logger.error(f"Error listing floors: {e}")
213
- exception_to_structured_error(e, context={"operation": "list_floors"}, suggestions=[
214
- "Check Home Assistant connection",
215
- "Verify WebSocket connection is active",
216
- ])
290
+ exception_to_structured_error(
291
+ e,
292
+ context={"operation": "list_floors"},
293
+ suggestions=[
294
+ "Check Home Assistant connection",
295
+ "Verify WebSocket connection is active",
296
+ ],
297
+ )
217
298
 
218
299
  @tool(
219
300
  name="ha_list_floors_areas",
220
301
  tags={"Areas & Floors"},
221
- annotations={"idempotentHint": True, "readOnlyHint": True, "title": "List Floors and Areas"},
302
+ annotations={
303
+ "idempotentHint": True,
304
+ "readOnlyHint": True,
305
+ "title": "List Floors and Areas",
306
+ },
222
307
  )
223
308
  @log_tool_usage
224
309
  async def ha_list_floors_areas(self) -> dict[str, Any]:
@@ -251,20 +336,22 @@ class AreaTools:
251
336
  areas_ok = areas_result.get("success") and "result" in areas_result
252
337
  floors_ok = floors_result.get("success") and "result" in floors_result
253
338
  if not (areas_ok and floors_ok):
254
- raise_tool_error(create_error_response(
255
- ErrorCode.SERVICE_CALL_FAILED,
256
- "Failed to retrieve area or floor registry",
257
- context={
258
- "areas_success": areas_result.get("success"),
259
- "floors_success": floors_result.get("success"),
260
- "areas_response_keys": sorted(areas_result.keys()),
261
- "floors_response_keys": sorted(floors_result.keys()),
262
- },
263
- suggestions=[
264
- "Check Home Assistant connection",
265
- "Verify WebSocket connection is active",
266
- ],
267
- ))
339
+ raise_tool_error(
340
+ create_error_response(
341
+ ErrorCode.SERVICE_CALL_FAILED,
342
+ "Failed to retrieve area or floor registry",
343
+ context={
344
+ "areas_success": areas_result.get("success"),
345
+ "floors_success": floors_result.get("success"),
346
+ "areas_response_keys": sorted(areas_result.keys()),
347
+ "floors_response_keys": sorted(floors_result.keys()),
348
+ },
349
+ suggestions=[
350
+ "Check Home Assistant connection",
351
+ "Verify WebSocket connection is active",
352
+ ],
353
+ )
354
+ )
268
355
 
269
356
  areas = areas_result["result"]
270
357
  floors = floors_result["result"]
@@ -361,7 +448,10 @@ class AreaTools:
361
448
  @tool(
362
449
  name="ha_set_area_or_floor",
363
450
  tags={"Areas & Floors"},
364
- annotations={"destructiveHint": True, "title": "Create or Update Area or Floor"},
451
+ annotations={
452
+ "destructiveHint": True,
453
+ "title": "Create or Update Area or Floor",
454
+ },
365
455
  )
366
456
  @log_tool_usage
367
457
  async def ha_set_area_or_floor(
@@ -439,10 +529,12 @@ class AreaTools:
439
529
  try:
440
530
  parsed_aliases = parse_string_list_param(aliases, "aliases")
441
531
  except ValueError as e:
442
- raise_tool_error(create_error_response(
443
- ErrorCode.VALIDATION_INVALID_PARAMETER,
444
- f"Invalid aliases parameter: {e}",
445
- ))
532
+ raise_tool_error(
533
+ create_error_response(
534
+ ErrorCode.VALIDATION_INVALID_PARAMETER,
535
+ f"Invalid aliases parameter: {e}",
536
+ )
537
+ )
446
538
 
447
539
  # Reject cross-kind params loudly so silent intent loss can't happen
448
540
  # (e.g., kind='floor' with picture='...' previously dropped the picture
@@ -456,15 +548,17 @@ class AreaTools:
456
548
  if picture is not None:
457
549
  cross_kind_params.append("picture")
458
550
  if cross_kind_params:
459
- raise_tool_error(create_error_response(
460
- ErrorCode.VALIDATION_INVALID_PARAMETER,
461
- f"Parameter(s) {cross_kind_params} are not valid for kind={kind!r}",
462
- context={"kind": kind, "invalid_parameters": cross_kind_params},
463
- suggestions=[
464
- "For kind='area' use: name, id, floor_id, icon, aliases, picture",
465
- "For kind='floor' use: name, id, level, icon, aliases",
466
- ],
467
- ))
551
+ raise_tool_error(
552
+ create_error_response(
553
+ ErrorCode.VALIDATION_INVALID_PARAMETER,
554
+ f"Parameter(s) {cross_kind_params} are not valid for kind={kind!r}",
555
+ context={"kind": kind, "invalid_parameters": cross_kind_params},
556
+ suggestions=[
557
+ "For kind='area' use: name, id, floor_id, icon, aliases, picture",
558
+ "For kind='floor' use: name, id, level, icon, aliases",
559
+ ],
560
+ )
561
+ )
468
562
 
469
563
  # ``None`` stays the documented "create-new" sentinel; explicit
470
564
  # empty/whitespace would silently route to the ``if id:`` create
@@ -483,7 +577,12 @@ class AreaTools:
483
577
  if kind == "area":
484
578
  if id:
485
579
  message = self._build_area_update_message(
486
- id, name, floor_id, icon, parsed_aliases, picture,
580
+ id,
581
+ name,
582
+ floor_id,
583
+ icon,
584
+ parsed_aliases,
585
+ picture,
487
586
  )
488
587
  operation = "update"
489
588
  else:
@@ -497,7 +596,11 @@ class AreaTools:
497
596
  suggestions=["Provide a non-empty name for the new area"],
498
597
  )
499
598
  message = self._build_area_create_message(
500
- name, floor_id, icon, parsed_aliases, picture,
599
+ name,
600
+ floor_id,
601
+ icon,
602
+ parsed_aliases,
603
+ picture,
501
604
  )
502
605
  operation = "create"
503
606
  result_key = "area"
@@ -505,7 +608,11 @@ class AreaTools:
505
608
  else: # kind == "floor"
506
609
  if id:
507
610
  message = self._build_floor_update_message(
508
- id, name, level, icon, parsed_aliases,
611
+ id,
612
+ name,
613
+ level,
614
+ icon,
615
+ parsed_aliases,
509
616
  )
510
617
  operation = "update"
511
618
  else:
@@ -517,7 +624,10 @@ class AreaTools:
517
624
  suggestions=["Provide a non-empty name for the new floor"],
518
625
  )
519
626
  message = self._build_floor_create_message(
520
- name, level, icon, parsed_aliases,
627
+ name,
628
+ level,
629
+ icon,
630
+ parsed_aliases,
521
631
  )
522
632
  operation = "create"
523
633
  result_key = "floor"
@@ -538,17 +648,23 @@ class AreaTools:
538
648
  }
539
649
 
540
650
  error = result.get("error", {})
541
- error_msg = error.get("message", str(error)) if isinstance(error, dict) else str(error)
651
+ error_msg = (
652
+ error.get("message", str(error))
653
+ if isinstance(error, dict)
654
+ else str(error)
655
+ )
542
656
  ctx: dict[str, Any] = {"operation": operation, "kind": kind}
543
657
  if name:
544
658
  ctx["name"] = name
545
659
  if id:
546
660
  ctx[id_key] = id
547
- raise_tool_error(create_error_response(
548
- ErrorCode.SERVICE_CALL_FAILED,
549
- f"Failed to {operation} {kind}: {error_msg}",
550
- context=ctx,
551
- ))
661
+ raise_tool_error(
662
+ create_error_response(
663
+ ErrorCode.SERVICE_CALL_FAILED,
664
+ f"Failed to {operation} {kind}: {error_msg}",
665
+ context=ctx,
666
+ )
667
+ )
552
668
 
553
669
  except ToolError:
554
670
  raise
@@ -570,7 +686,11 @@ class AreaTools:
570
686
  @tool(
571
687
  name="ha_remove_area_or_floor",
572
688
  tags={"Areas & Floors"},
573
- annotations={"destructiveHint": True, "idempotentHint": True, "title": "Remove Area or Floor"},
689
+ annotations={
690
+ "destructiveHint": True,
691
+ "idempotentHint": True,
692
+ "title": "Remove Area or Floor",
693
+ },
574
694
  )
575
695
  @log_tool_usage
576
696
  async def ha_remove_area_or_floor(
@@ -581,7 +701,9 @@ class AreaTools:
581
701
  ],
582
702
  id: Annotated[ # noqa: A002
583
703
  str,
584
- Field(description="Area ID or floor ID to delete (use ha_list_floors_areas to find IDs)"),
704
+ Field(
705
+ description="Area ID or floor ID to delete (use ha_list_floors_areas to find IDs)"
706
+ ),
585
707
  ],
586
708
  ) -> dict[str, Any]:
587
709
  """Remove a Home Assistant area or floor.
@@ -618,12 +740,18 @@ class AreaTools:
618
740
  }
619
741
 
620
742
  error = result.get("error", {})
621
- error_msg = error.get("message", str(error)) if isinstance(error, dict) else str(error)
622
- raise_tool_error(create_error_response(
623
- ErrorCode.SERVICE_CALL_FAILED,
624
- f"Failed to remove {kind}: {error_msg}",
625
- context={"kind": kind, id_key: id},
626
- ))
743
+ error_msg = (
744
+ error.get("message", str(error))
745
+ if isinstance(error, dict)
746
+ else str(error)
747
+ )
748
+ raise_tool_error(
749
+ create_error_response(
750
+ ErrorCode.SERVICE_CALL_FAILED,
751
+ f"Failed to remove {kind}: {error_msg}",
752
+ context={"kind": kind, id_key: id},
753
+ )
754
+ )
627
755
 
628
756
  except ToolError:
629
757
  raise