ha-mcp-dev 7.2.0.dev346__tar.gz → 7.2.0.dev348__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 (101) hide show
  1. {ha_mcp_dev-7.2.0.dev346/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.2.0.dev348}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/pyproject.toml +36 -2
  3. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/helpers.py +19 -0
  4. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_camera.py +57 -47
  5. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_categories.py +32 -27
  6. ha_mcp_dev-7.2.0.dev348/src/ha_mcp/tools/tools_history.py +563 -0
  7. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_labels.py +37 -13
  8. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_voice_assistant.py +80 -71
  9. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_zones.py +61 -43
  10. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  11. ha_mcp_dev-7.2.0.dev346/src/ha_mcp/tools/tools_history.py +0 -725
  12. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/LICENSE +0 -0
  13. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/MANIFEST.in +0 -0
  14. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/README.md +0 -0
  15. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/setup.cfg +0 -0
  16. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/__init__.py +0 -0
  17. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/__main__.py +0 -0
  18. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/_pypi_marker +0 -0
  19. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/auth/__init__.py +0 -0
  20. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/auth/consent_form.py +0 -0
  21. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/auth/provider.py +0 -0
  22. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/client/__init__.py +0 -0
  23. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/client/rest_client.py +0 -0
  24. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/client/websocket_client.py +0 -0
  25. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/client/websocket_listener.py +0 -0
  26. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/config.py +0 -0
  27. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/errors.py +0 -0
  28. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/py.typed +0 -0
  29. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  30. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  31. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  32. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  33. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  34. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  35. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  36. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  37. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  38. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  39. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  40. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  41. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  42. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  43. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  44. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  45. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  46. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  47. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  48. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  49. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/server.py +0 -0
  50. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/smoke_test.py +0 -0
  51. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/__init__.py +0 -0
  52. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/backup.py +0 -0
  53. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  54. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/device_control.py +0 -0
  55. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/enhanced.py +0 -0
  56. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/registry.py +0 -0
  57. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/smart_search.py +0 -0
  58. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_addons.py +0 -0
  59. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_areas.py +0 -0
  60. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  61. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  62. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_calendar.py +0 -0
  63. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  64. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  65. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  66. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  67. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  68. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_entities.py +0 -0
  69. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  70. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_groups.py +0 -0
  71. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_hacs.py +0 -0
  72. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_integrations.py +0 -0
  73. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  74. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_registry.py +0 -0
  75. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_resources.py +0 -0
  76. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_search.py +0 -0
  77. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_service.py +0 -0
  78. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_services.py +0 -0
  79. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_system.py +0 -0
  80. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_todo.py +0 -0
  81. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_traces.py +0 -0
  82. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_updates.py +0 -0
  83. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_utility.py +0 -0
  84. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  85. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/util_helpers.py +0 -0
  86. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/transforms/__init__.py +0 -0
  87. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/transforms/categorized_search.py +0 -0
  88. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/utils/__init__.py +0 -0
  89. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/utils/domain_handlers.py +0 -0
  90. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  91. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/utils/operation_manager.py +0 -0
  92. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/utils/python_sandbox.py +0 -0
  93. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/utils/usage_logger.py +0 -0
  94. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  95. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  96. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  97. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  98. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  99. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/tests/__init__.py +0 -0
  100. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/tests/test_constants.py +0 -0
  101. {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/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.2.0.dev346
3
+ Version: 7.2.0.dev348
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.2.0.dev346"
7
+ version = "7.2.0.dev348"
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"
@@ -133,7 +133,41 @@ ignore = [
133
133
  [tool.ruff.lint.per-file-ignores]
134
134
  "__init__.py" = ["F401"]
135
135
  "tests/**/*" = ["E501", "B011"]
136
- "src/ha_mcp/tools/**" = ["C901"]
136
+ # C901 ignores for unmigrated tools files (see #925).
137
+ # Remove lines as files are migrated to class-based pattern.
138
+ "src/ha_mcp/tools/backup.py" = ["C901"]
139
+ "src/ha_mcp/tools/best_practice_checker.py" = ["C901"]
140
+ "src/ha_mcp/tools/device_control.py" = ["C901"]
141
+ "src/ha_mcp/tools/helpers.py" = ["C901"]
142
+ "src/ha_mcp/tools/registry.py" = ["C901"]
143
+ "src/ha_mcp/tools/smart_search.py" = ["C901"]
144
+ "src/ha_mcp/tools/tools_addons.py" = ["C901"]
145
+ "src/ha_mcp/tools/tools_areas.py" = ["C901"]
146
+ "src/ha_mcp/tools/tools_blueprints.py" = ["C901"]
147
+ "src/ha_mcp/tools/tools_calendar.py" = ["C901"]
148
+ "src/ha_mcp/tools/tools_config_automations.py" = ["C901"]
149
+ "src/ha_mcp/tools/tools_config_dashboards.py" = ["C901"]
150
+ "src/ha_mcp/tools/tools_config_entry_flow.py" = ["C901"]
151
+ "src/ha_mcp/tools/tools_config_helpers.py" = ["C901"]
152
+ "src/ha_mcp/tools/tools_config_scripts.py" = ["C901"]
153
+ "src/ha_mcp/tools/tools_entities.py" = ["C901"]
154
+ "src/ha_mcp/tools/tools_filesystem.py" = ["C901"]
155
+ "src/ha_mcp/tools/tools_groups.py" = ["C901"]
156
+ "src/ha_mcp/tools/tools_hacs.py" = ["C901"]
157
+ "src/ha_mcp/tools/tools_history.py" = ["C901"]
158
+ "src/ha_mcp/tools/tools_integrations.py" = ["C901"]
159
+ "src/ha_mcp/tools/tools_mcp_component.py" = ["C901"]
160
+ "src/ha_mcp/tools/tools_registry.py" = ["C901"]
161
+ "src/ha_mcp/tools/tools_resources.py" = ["C901"]
162
+ "src/ha_mcp/tools/tools_search.py" = ["C901"]
163
+ "src/ha_mcp/tools/tools_service.py" = ["C901"]
164
+ "src/ha_mcp/tools/tools_services.py" = ["C901"]
165
+ "src/ha_mcp/tools/tools_system.py" = ["C901"]
166
+ "src/ha_mcp/tools/tools_todo.py" = ["C901"]
167
+ "src/ha_mcp/tools/tools_traces.py" = ["C901"]
168
+ "src/ha_mcp/tools/tools_updates.py" = ["C901"]
169
+ "src/ha_mcp/tools/tools_utility.py" = ["C901"]
170
+ "src/ha_mcp/tools/util_helpers.py" = ["C901"]
137
171
 
138
172
  [tool.pytest.ini_options]
139
173
  testpaths = ["tests"]
@@ -299,3 +299,22 @@ def log_tool_usage(func: Any) -> Any:
299
299
  )
300
300
 
301
301
  return wrapper
302
+
303
+
304
+ def register_tool_methods(mcp: Any, instance: Any) -> None:
305
+ """Register all @tool-decorated methods from a class instance with the MCP server.
306
+
307
+ Discovers methods bearing a ``__fastmcp__`` attribute (set by the outermost
308
+ ``@tool`` decorator — must be listed above ``@log_tool_usage``) and registers
309
+ them via ``mcp.add_tool()``.
310
+ """
311
+ count = 0
312
+ for attr in dir(instance):
313
+ method = getattr(instance, attr)
314
+ if callable(method) and hasattr(method, "__fastmcp__"):
315
+ mcp.add_tool(method)
316
+ count += 1
317
+ if count == 0:
318
+ logger.warning(
319
+ f"No @tool-decorated methods found on {type(instance).__name__}"
320
+ )
@@ -8,19 +8,61 @@ that returns images directly to the LLM for visual analysis.
8
8
  import logging
9
9
  from typing import Any
10
10
 
11
+ from fastmcp.tools import tool
11
12
  from fastmcp.utilities.types import Image
12
13
 
13
- from .helpers import log_tool_usage
14
+ from .helpers import log_tool_usage, register_tool_methods
14
15
 
15
16
  logger = logging.getLogger(__name__)
16
17
 
17
18
 
18
- def register_camera_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
19
- """Register Home Assistant camera tools."""
19
+ _CONTENT_TYPE_MAP = {
20
+ "jpeg": "jpeg", "jpg": "jpeg", "png": "png", "gif": "gif",
21
+ }
22
+
23
+
24
+ def _detect_image_format(content_type: str) -> str:
25
+ """Detect image format from Content-Type header, defaulting to JPEG."""
26
+ for key, fmt in _CONTENT_TYPE_MAP.items():
27
+ if key in content_type:
28
+ return fmt
29
+ return "jpeg"
30
+
20
31
 
21
- @mcp.tool(tags={"Camera"}, annotations={"idempotentHint": True, "readOnlyHint": True, "title": "Get Camera Image"})
32
+ class CameraTools:
33
+ """Camera snapshot retrieval tools."""
34
+
35
+ def __init__(self, client: Any) -> None:
36
+ self._client = client
37
+
38
+ @staticmethod
39
+ def _check_response(response: Any, entity_id: str) -> None:
40
+ """Validate camera proxy HTTP response status and content, raising on errors."""
41
+ if response.status_code == 401:
42
+ raise PermissionError("Invalid authentication token for camera access")
43
+ if response.status_code == 404:
44
+ raise ValueError(
45
+ f"Camera entity not found: {entity_id}. "
46
+ "Use ha_search_entities() to find available cameras."
47
+ )
48
+ if response.status_code >= 400:
49
+ raise RuntimeError(
50
+ f"Failed to retrieve camera image: HTTP {response.status_code}"
51
+ )
52
+ if not response.content:
53
+ raise RuntimeError(
54
+ f"Camera {entity_id} returned empty image data. "
55
+ "The camera may be offline or unavailable."
56
+ )
57
+
58
+ @tool(
59
+ name="ha_get_camera_image",
60
+ tags={"Camera"},
61
+ annotations={"idempotentHint": True, "readOnlyHint": True, "title": "Get Camera Image"},
62
+ )
22
63
  @log_tool_usage
23
64
  async def ha_get_camera_image(
65
+ self,
24
66
  entity_id: str,
25
67
  width: int | None = None,
26
68
  height: int | None = None,
@@ -64,14 +106,12 @@ def register_camera_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
64
106
  - camera.turn_on/turn_off: Control camera power
65
107
  - camera.enable_motion_detection: Enable motion detection
66
108
  """
67
- # Validate entity_id format
68
109
  if not entity_id or "." not in entity_id:
69
110
  raise ValueError(
70
111
  f"Invalid entity_id format: {entity_id}. "
71
112
  "Expected format: camera.entity_name"
72
113
  )
73
114
 
74
- # Validate domain is camera
75
115
  domain = entity_id.split(".")[0]
76
116
  if domain != "camera":
77
117
  raise ValueError(
@@ -90,54 +130,19 @@ def register_camera_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
90
130
  params["height"] = str(height)
91
131
 
92
132
  try:
93
- # Use the client's httpx_client directly for binary image data
94
- response = await client.httpx_client.get(endpoint, params=params or None)
95
-
96
- # Handle authentication errors
97
- if response.status_code == 401:
98
- raise PermissionError("Invalid authentication token for camera access")
99
-
100
- # Handle not found errors
101
- if response.status_code == 404:
102
- raise ValueError(
103
- f"Camera entity not found: {entity_id}. "
104
- "Use ha_search_entities() to find available cameras."
105
- )
106
-
107
- # Handle other HTTP errors
108
- if response.status_code >= 400:
109
- raise RuntimeError(
110
- f"Failed to retrieve camera image: HTTP {response.status_code}"
111
- )
112
-
113
- # Get the image bytes
114
- image_data = response.content
115
-
116
- if not image_data:
117
- raise RuntimeError(
118
- f"Camera {entity_id} returned empty image data. "
119
- "The camera may be offline or unavailable."
120
- )
121
-
122
- # Determine MIME type from response headers or default to JPEG
133
+ response = await self._client.httpx_client.get(endpoint, params=params or None)
134
+ self._check_response(response, entity_id)
135
+
123
136
  content_type = response.headers.get("content-type", "image/jpeg")
124
- if "jpeg" in content_type or "jpg" in content_type:
125
- image_format = "jpeg"
126
- elif "png" in content_type:
127
- image_format = "png"
128
- elif "gif" in content_type:
129
- image_format = "gif"
130
- else:
131
- # Default to JPEG as Home Assistant camera proxy typically returns JPEG
132
- image_format = "jpeg"
137
+ image_format = _detect_image_format(content_type)
133
138
 
134
139
  logger.info(
135
140
  f"Retrieved camera image from {entity_id} "
136
- f"({len(image_data)} bytes, format={image_format})"
141
+ f"({len(response.content)} bytes, format={image_format})"
137
142
  )
138
143
 
139
144
  # Return FastMCP Image object which automatically converts to MCP ImageContent
140
- return Image(data=image_data, format=image_format)
145
+ return Image(data=response.content, format=image_format)
141
146
 
142
147
  except (PermissionError, ValueError, RuntimeError):
143
148
  raise
@@ -147,3 +152,8 @@ def register_camera_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
147
152
  f"Failed to retrieve camera image from {entity_id}: {str(e)}. "
148
153
  "Ensure the camera is online and accessible."
149
154
  ) from e
155
+
156
+
157
+ def register_camera_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
158
+ """Register Home Assistant camera tools."""
159
+ register_tool_methods(mcp, CameraTools(client))
@@ -12,27 +12,34 @@ import logging
12
12
  from typing import Annotated, Any
13
13
 
14
14
  from fastmcp.exceptions import ToolError
15
+ from fastmcp.tools import tool
15
16
  from pydantic import Field
16
17
 
17
18
  from ..errors import ErrorCode, create_error_response
18
- from .helpers import exception_to_structured_error, log_tool_usage, raise_tool_error
19
+ from .helpers import (
20
+ exception_to_structured_error,
21
+ log_tool_usage,
22
+ raise_tool_error,
23
+ register_tool_methods,
24
+ )
19
25
 
20
26
  logger = logging.getLogger(__name__)
21
27
 
22
28
 
23
- def register_category_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
24
- """Register Home Assistant category management tools."""
29
+ class CategoryTools:
30
+ """Category management tools for Home Assistant."""
25
31
 
26
- @mcp.tool(
32
+ def __init__(self, client: Any) -> None:
33
+ self._client = client
34
+
35
+ @tool(
36
+ name="ha_config_get_category",
27
37
  tags={"Labels & Categories"},
28
- annotations={
29
- "idempotentHint": True,
30
- "readOnlyHint": True,
31
- "title": "Get Category"
32
- }
38
+ annotations={"idempotentHint": True, "readOnlyHint": True, "title": "Get Category"},
33
39
  )
34
40
  @log_tool_usage
35
41
  async def ha_config_get_category(
42
+ self,
36
43
  scope: Annotated[
37
44
  str,
38
45
  Field(
@@ -74,7 +81,7 @@ def register_category_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
74
81
  "scope": scope,
75
82
  }
76
83
 
77
- result = await client.send_websocket_message(message)
84
+ result = await self._client.send_websocket_message(message)
78
85
 
79
86
  if not result.get("success"):
80
87
  raise_tool_error(
@@ -87,7 +94,6 @@ def register_category_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
87
94
 
88
95
  categories = result.get("result", [])
89
96
 
90
- # List mode - return all categories
91
97
  if category_id is None:
92
98
  return {
93
99
  "success": True,
@@ -97,7 +103,6 @@ def register_category_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
97
103
  "message": f"Found {len(categories)} category(ies) for scope '{scope}'",
98
104
  }
99
105
 
100
- # Get mode - find specific category
101
106
  category = next(
102
107
  (cat for cat in categories if cat.get("category_id") == category_id),
103
108
  None,
@@ -142,15 +147,14 @@ def register_category_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
142
147
  ],
143
148
  )
144
149
 
145
- @mcp.tool(
150
+ @tool(
151
+ name="ha_config_set_category",
146
152
  tags={"Labels & Categories"},
147
- annotations={
148
- "destructiveHint": True,
149
- "title": "Create or Update Category"
150
- }
153
+ annotations={"destructiveHint": True, "title": "Create or Update Category"},
151
154
  )
152
155
  @log_tool_usage
153
156
  async def ha_config_set_category(
157
+ self,
154
158
  name: Annotated[str, Field(description="Display name for the category")],
155
159
  scope: Annotated[
156
160
  str,
@@ -190,7 +194,6 @@ def register_category_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
190
194
  After creating a category, use ha_set_entity(categories={"automation": "category_id"}) to assign it.
191
195
  """
192
196
  try:
193
- # Determine if this is a create or update
194
197
  action = "update" if category_id else "create"
195
198
 
196
199
  message: dict[str, Any] = {
@@ -202,11 +205,10 @@ def register_category_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
202
205
  if action == "update":
203
206
  message["category_id"] = category_id
204
207
 
205
- # Add optional fields only if they are explicitly provided (not None)
206
208
  if icon is not None:
207
209
  message["icon"] = icon
208
210
 
209
- result = await client.send_websocket_message(message)
211
+ result = await self._client.send_websocket_message(message)
210
212
 
211
213
  if result.get("success"):
212
214
  category_data = result.get("result", {})
@@ -245,16 +247,14 @@ def register_category_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
245
247
  ],
246
248
  )
247
249
 
248
- @mcp.tool(
250
+ @tool(
251
+ name="ha_config_remove_category",
249
252
  tags={"Labels & Categories"},
250
- annotations={
251
- "destructiveHint": True,
252
- "idempotentHint": True,
253
- "title": "Remove Category"
254
- }
253
+ annotations={"destructiveHint": True, "idempotentHint": True, "title": "Remove Category"},
255
254
  )
256
255
  @log_tool_usage
257
256
  async def ha_config_remove_category(
257
+ self,
258
258
  scope: Annotated[
259
259
  str,
260
260
  Field(
@@ -288,7 +288,7 @@ def register_category_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
288
288
  "category_id": category_id,
289
289
  }
290
290
 
291
- result = await client.send_websocket_message(message)
291
+ result = await self._client.send_websocket_message(message)
292
292
 
293
293
  if result.get("success"):
294
294
  return {
@@ -318,3 +318,8 @@ def register_category_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
318
318
  "Verify the category_id exists using ha_config_get_category()",
319
319
  ],
320
320
  )
321
+
322
+
323
+ def register_category_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
324
+ """Register Home Assistant category management tools."""
325
+ register_tool_methods(mcp, CategoryTools(client))