ha-mcp-dev 7.2.0.dev346__tar.gz → 7.2.0.dev347__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.dev347}/PKG-INFO +1 -1
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/pyproject.toml +36 -2
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/helpers.py +19 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_camera.py +57 -47
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_categories.py +32 -27
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_labels.py +37 -13
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_voice_assistant.py +80 -71
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_zones.py +61 -43
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/LICENSE +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/README.md +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/setup.cfg +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/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.dev347}/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.dev347}/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.dev347}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/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.dev347}/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.dev347}/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.dev347}/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.dev347}/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.dev347}/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.dev347}/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.dev347}/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.dev347}/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.dev347}/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.dev347}/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.dev347}/src/ha_mcp/server.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/smart_search.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.2.0.dev346 → ha_mcp_dev-7.2.0.dev347}/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.dev347"
|
|
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))
|
|
@@ -9,20 +9,34 @@ import logging
|
|
|
9
9
|
from typing import Annotated, Any
|
|
10
10
|
|
|
11
11
|
from fastmcp.exceptions import ToolError
|
|
12
|
+
from fastmcp.tools import tool
|
|
12
13
|
from pydantic import Field
|
|
13
14
|
|
|
14
15
|
from ..errors import ErrorCode, create_error_response
|
|
15
|
-
from .helpers import
|
|
16
|
+
from .helpers import (
|
|
17
|
+
exception_to_structured_error,
|
|
18
|
+
log_tool_usage,
|
|
19
|
+
raise_tool_error,
|
|
20
|
+
register_tool_methods,
|
|
21
|
+
)
|
|
16
22
|
|
|
17
23
|
logger = logging.getLogger(__name__)
|
|
18
24
|
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
"""
|
|
26
|
+
class LabelTools:
|
|
27
|
+
"""Label management tools for Home Assistant."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, client: Any) -> None:
|
|
30
|
+
self._client = client
|
|
22
31
|
|
|
23
|
-
@
|
|
32
|
+
@tool(
|
|
33
|
+
name="ha_config_get_label",
|
|
34
|
+
tags={"Labels & Categories"},
|
|
35
|
+
annotations={"idempotentHint": True, "readOnlyHint": True, "title": "Get Label"},
|
|
36
|
+
)
|
|
24
37
|
@log_tool_usage
|
|
25
38
|
async def ha_config_get_label(
|
|
39
|
+
self,
|
|
26
40
|
label_id: Annotated[
|
|
27
41
|
str | None,
|
|
28
42
|
Field(
|
|
@@ -53,7 +67,7 @@ def register_label_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
53
67
|
"type": "config/label_registry/list",
|
|
54
68
|
}
|
|
55
69
|
|
|
56
|
-
result = await
|
|
70
|
+
result = await self._client.send_websocket_message(message)
|
|
57
71
|
|
|
58
72
|
if not result.get("success"):
|
|
59
73
|
raise_tool_error(create_error_response(
|
|
@@ -64,7 +78,6 @@ def register_label_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
64
78
|
|
|
65
79
|
labels = result.get("result", [])
|
|
66
80
|
|
|
67
|
-
# List mode - return all labels
|
|
68
81
|
if label_id is None:
|
|
69
82
|
return {
|
|
70
83
|
"success": True,
|
|
@@ -73,7 +86,6 @@ def register_label_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
73
86
|
"message": f"Found {len(labels)} label(s)",
|
|
74
87
|
}
|
|
75
88
|
|
|
76
|
-
# Get mode - find specific label
|
|
77
89
|
label = next(
|
|
78
90
|
(lbl for lbl in labels if lbl.get("label_id") == label_id), None
|
|
79
91
|
)
|
|
@@ -103,9 +115,14 @@ def register_label_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
103
115
|
"Verify WebSocket connection is active",
|
|
104
116
|
])
|
|
105
117
|
|
|
106
|
-
@
|
|
118
|
+
@tool(
|
|
119
|
+
name="ha_config_set_label",
|
|
120
|
+
tags={"Labels & Categories"},
|
|
121
|
+
annotations={"destructiveHint": True, "title": "Create or Update Label"},
|
|
122
|
+
)
|
|
107
123
|
@log_tool_usage
|
|
108
124
|
async def ha_config_set_label(
|
|
125
|
+
self,
|
|
109
126
|
name: Annotated[str, Field(description="Display name for the label")],
|
|
110
127
|
label_id: Annotated[
|
|
111
128
|
str | None,
|
|
@@ -154,7 +171,6 @@ def register_label_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
154
171
|
After creating a label, use ha_set_entity(labels=["label_id"]) to assign it to entities.
|
|
155
172
|
"""
|
|
156
173
|
try:
|
|
157
|
-
# Determine if this is a create or update
|
|
158
174
|
action = "update" if label_id else "create"
|
|
159
175
|
|
|
160
176
|
message: dict[str, Any] = {
|
|
@@ -167,7 +183,6 @@ def register_label_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
167
183
|
# Note: name is always provided as it's a required parameter
|
|
168
184
|
# The validation of at least one field is satisfied by name being required
|
|
169
185
|
|
|
170
|
-
# Add optional fields only if they are explicitly provided (not None)
|
|
171
186
|
if color is not None:
|
|
172
187
|
message["color"] = color
|
|
173
188
|
if icon is not None:
|
|
@@ -175,7 +190,7 @@ def register_label_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
175
190
|
if description is not None:
|
|
176
191
|
message["description"] = description
|
|
177
192
|
|
|
178
|
-
result = await
|
|
193
|
+
result = await self._client.send_websocket_message(message)
|
|
179
194
|
|
|
180
195
|
if result.get("success"):
|
|
181
196
|
label_data = result.get("result", {})
|
|
@@ -203,9 +218,14 @@ def register_label_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
203
218
|
"For updates, verify the label_id exists using ha_config_get_label()",
|
|
204
219
|
])
|
|
205
220
|
|
|
206
|
-
@
|
|
221
|
+
@tool(
|
|
222
|
+
name="ha_config_remove_label",
|
|
223
|
+
tags={"Labels & Categories"},
|
|
224
|
+
annotations={"destructiveHint": True, "idempotentHint": True, "title": "Remove Label"},
|
|
225
|
+
)
|
|
207
226
|
@log_tool_usage
|
|
208
227
|
async def ha_config_remove_label(
|
|
228
|
+
self,
|
|
209
229
|
label_id: Annotated[
|
|
210
230
|
str,
|
|
211
231
|
Field(description="ID of the label to delete"),
|
|
@@ -231,7 +251,7 @@ def register_label_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
231
251
|
"label_id": label_id,
|
|
232
252
|
}
|
|
233
253
|
|
|
234
|
-
result = await
|
|
254
|
+
result = await self._client.send_websocket_message(message)
|
|
235
255
|
|
|
236
256
|
if result.get("success"):
|
|
237
257
|
return {
|
|
@@ -255,3 +275,7 @@ def register_label_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
255
275
|
"Verify the label_id exists using ha_config_get_label()",
|
|
256
276
|
])
|
|
257
277
|
|
|
278
|
+
|
|
279
|
+
def register_label_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
280
|
+
"""Register Home Assistant label management tools."""
|
|
281
|
+
register_tool_methods(mcp, LabelTools(client))
|