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.
- {ha_mcp_dev-7.2.0.dev346/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.2.0.dev348}/PKG-INFO +1 -1
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/pyproject.toml +36 -2
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/helpers.py +19 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_camera.py +57 -47
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_categories.py +32 -27
- ha_mcp_dev-7.2.0.dev348/src/ha_mcp/tools/tools_history.py +563 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_labels.py +37 -13
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_voice_assistant.py +80 -71
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_zones.py +61 -43
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- ha_mcp_dev-7.2.0.dev346/src/ha_mcp/tools/tools_history.py +0 -725
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/LICENSE +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/README.md +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/setup.cfg +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/py.typed +0 -0
- {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
- {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
- {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
- {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
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/server.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/smart_search.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {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
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
- {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
- {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
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {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
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev348}/tests/test_env_manager.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ha-mcp-dev"
|
|
7
|
-
version = "7.2.0.
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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(
|
|
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=
|
|
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
|
|
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
|
-
|
|
24
|
-
"""
|
|
29
|
+
class CategoryTools:
|
|
30
|
+
"""Category management tools for Home Assistant."""
|
|
25
31
|
|
|
26
|
-
|
|
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
|
|
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
|
-
@
|
|
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
|
|
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
|
-
@
|
|
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
|
|
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))
|