ha-mcp-dev 7.3.0.dev391__tar.gz → 7.3.0.dev393__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.3.0.dev391/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.3.0.dev393}/PKG-INFO +2 -2
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/README.md +1 -1
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/pyproject.toml +1 -1
- ha_mcp_dev-7.3.0.dev393/src/ha_mcp/tools/tools_energy.py +626 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_groups.py +17 -5
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393/src/ha_mcp_dev.egg-info}/PKG-INFO +2 -2
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp_dev.egg-info/SOURCES.txt +1 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/LICENSE +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/setup.cfg +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/server.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/smart_search.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/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.3.0.
|
|
3
|
+
Version: 7.3.0.dev393
|
|
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
|
|
@@ -205,7 +205,7 @@ Spend less time configuring, more time enjoying your smart home.
|
|
|
205
205
|
| **Service & Device Control** | `ha_bulk_control`, `ha_call_service`, `ha_get_operation_status`, `ha_list_services` |
|
|
206
206
|
| **System** | `ha_backup_create`, `ha_backup_restore`, `ha_check_config`, `ha_config_set_yaml` *(beta)*, `ha_get_system_health`, `ha_get_updates`, `ha_reload_core`, `ha_restart` |
|
|
207
207
|
| **Todo Lists** | `ha_get_todo`, `ha_remove_todo_item`, `ha_set_todo_item` |
|
|
208
|
-
| **Utilities** | `ha_eval_template`, `ha_install_mcp_tools
|
|
208
|
+
| **Utilities** | `ha_eval_template`, `ha_install_mcp_tools` *(beta)*, `ha_report_issue` |
|
|
209
209
|
| **Zones** | `ha_get_zone`, `ha_remove_zone`, `ha_set_zone` |
|
|
210
210
|
|
|
211
211
|
<!-- TOOLS_TABLE_END -->
|
|
@@ -176,7 +176,7 @@ Spend less time configuring, more time enjoying your smart home.
|
|
|
176
176
|
| **Service & Device Control** | `ha_bulk_control`, `ha_call_service`, `ha_get_operation_status`, `ha_list_services` |
|
|
177
177
|
| **System** | `ha_backup_create`, `ha_backup_restore`, `ha_check_config`, `ha_config_set_yaml` *(beta)*, `ha_get_system_health`, `ha_get_updates`, `ha_reload_core`, `ha_restart` |
|
|
178
178
|
| **Todo Lists** | `ha_get_todo`, `ha_remove_todo_item`, `ha_set_todo_item` |
|
|
179
|
-
| **Utilities** | `ha_eval_template`, `ha_install_mcp_tools
|
|
179
|
+
| **Utilities** | `ha_eval_template`, `ha_install_mcp_tools` *(beta)*, `ha_report_issue` |
|
|
180
180
|
| **Zones** | `ha_get_zone`, `ha_remove_zone`, `ha_set_zone` |
|
|
181
181
|
|
|
182
182
|
<!-- TOOLS_TABLE_END -->
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ha-mcp-dev"
|
|
7
|
-
version = "7.3.0.
|
|
7
|
+
version = "7.3.0.dev393"
|
|
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"
|
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Energy Dashboard preference management tools for Home Assistant.
|
|
3
|
+
|
|
4
|
+
This module provides a single tool to read and write Home Assistant's Energy
|
|
5
|
+
Dashboard configuration through the ``energy/get_prefs`` / ``energy/save_prefs``
|
|
6
|
+
WebSocket commands. The underlying API has destructive full-replace semantics
|
|
7
|
+
per top-level key (``energy_sources``, ``device_consumption``,
|
|
8
|
+
``device_consumption_water``) — sending a key with a partial list silently
|
|
9
|
+
deletes everything else the user had configured. Optimistic locking via
|
|
10
|
+
``config_hash`` prevents concurrent-modification data loss; a local shape
|
|
11
|
+
check catches the most common agent-side errors; and a server-side
|
|
12
|
+
``energy/validate`` call after every write surfaces residual issues
|
|
13
|
+
(missing stats, wrong unit classes, etc.) in the response.
|
|
14
|
+
|
|
15
|
+
Note: ``energy/validate`` in Home Assistant Core takes no payload — it
|
|
16
|
+
validates the currently-persisted config. Pre-write validation of an
|
|
17
|
+
unsubmitted payload is therefore not possible; this tool validates the
|
|
18
|
+
post-save state instead.
|
|
19
|
+
|
|
20
|
+
Note: On a fresh Home Assistant instance that has never had the Energy
|
|
21
|
+
Dashboard configured, ``energy/get_prefs`` returns
|
|
22
|
+
``ERR_NOT_FOUND "No prefs"`` rather than an empty default. The tool
|
|
23
|
+
transparently maps that case to the documented default preferences
|
|
24
|
+
structure (all three top-level keys present, empty lists) so agents
|
|
25
|
+
get uniform behavior on fresh and configured instances alike.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
from typing import Annotated, Any, Literal
|
|
30
|
+
|
|
31
|
+
from fastmcp.exceptions import ToolError
|
|
32
|
+
from fastmcp.tools import tool
|
|
33
|
+
from pydantic import Field
|
|
34
|
+
|
|
35
|
+
from ..errors import ErrorCode, create_error_response
|
|
36
|
+
from ..utils.config_hash import compute_config_hash
|
|
37
|
+
from .helpers import (
|
|
38
|
+
exception_to_structured_error,
|
|
39
|
+
log_tool_usage,
|
|
40
|
+
raise_tool_error,
|
|
41
|
+
register_tool_methods,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Top-level keys in the energy prefs payload. Each is an independent
|
|
48
|
+
# full-replace slot in ``energy/save_prefs``.
|
|
49
|
+
_PREFS_TOP_LEVEL_KEYS = (
|
|
50
|
+
"energy_sources",
|
|
51
|
+
"device_consumption",
|
|
52
|
+
"device_consumption_water",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _default_prefs() -> dict[str, Any]:
|
|
57
|
+
"""Return the default empty prefs structure used by HA Core.
|
|
58
|
+
|
|
59
|
+
Mirrors ``EnergyManager.default_preferences()`` in
|
|
60
|
+
``homeassistant/components/energy/data.py``. A Home Assistant instance
|
|
61
|
+
that has never had the Energy Dashboard configured returns
|
|
62
|
+
``ERR_NOT_FOUND "No prefs"`` from ``energy/get_prefs``; this helper
|
|
63
|
+
provides the canonical empty structure so the tool can transparently
|
|
64
|
+
treat the two cases (never-configured vs. configured-but-empty) the
|
|
65
|
+
same way.
|
|
66
|
+
"""
|
|
67
|
+
return {
|
|
68
|
+
"energy_sources": [],
|
|
69
|
+
"device_consumption": [],
|
|
70
|
+
"device_consumption_water": [],
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_no_prefs_error(error_msg: str) -> bool:
|
|
75
|
+
"""True if an error string from send_websocket_message indicates
|
|
76
|
+
``ERR_NOT_FOUND "No prefs"`` from HA Core's energy/get_prefs handler.
|
|
77
|
+
|
|
78
|
+
HA Core wraps the error as ``f"Command failed: {message}"``; the
|
|
79
|
+
underlying sentinel we key on is the literal ``"No prefs"`` message
|
|
80
|
+
emitted by ``ws_get_prefs`` when ``manager.data is None``.
|
|
81
|
+
"""
|
|
82
|
+
return error_msg.endswith("No prefs")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _flatten_validation_errors(raw: Any) -> list[dict[str, str]]:
|
|
86
|
+
"""Convert the raw ``energy/validate`` response into a flat error list.
|
|
87
|
+
|
|
88
|
+
The raw response mirrors the prefs structure: a dict with the three
|
|
89
|
+
top-level keys, each mapping to a list of per-entry error lists (empty
|
|
90
|
+
inner list = that entry is valid). This function walks that structure and
|
|
91
|
+
returns a flat list of ``{"path", "message"}`` dicts, suitable for agent
|
|
92
|
+
consumption.
|
|
93
|
+
|
|
94
|
+
A successful validation returns an empty list.
|
|
95
|
+
"""
|
|
96
|
+
if not isinstance(raw, dict):
|
|
97
|
+
return []
|
|
98
|
+
|
|
99
|
+
errors: list[dict[str, str]] = []
|
|
100
|
+
for key in _PREFS_TOP_LEVEL_KEYS:
|
|
101
|
+
entries = raw.get(key, [])
|
|
102
|
+
if not isinstance(entries, list):
|
|
103
|
+
continue
|
|
104
|
+
for idx, entry_errors in enumerate(entries):
|
|
105
|
+
if not entry_errors:
|
|
106
|
+
continue
|
|
107
|
+
if isinstance(entry_errors, list):
|
|
108
|
+
errors.extend(
|
|
109
|
+
{"path": f"{key}[{idx}]", "message": str(msg)}
|
|
110
|
+
for msg in entry_errors
|
|
111
|
+
)
|
|
112
|
+
elif isinstance(entry_errors, dict):
|
|
113
|
+
for field, msgs in entry_errors.items():
|
|
114
|
+
msg_list = msgs if isinstance(msgs, list) else [msgs]
|
|
115
|
+
errors.extend(
|
|
116
|
+
{"path": f"{key}[{idx}].{field}", "message": str(msg)}
|
|
117
|
+
for msg in msg_list
|
|
118
|
+
)
|
|
119
|
+
return errors
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _shape_check(config: dict[str, Any]) -> list[dict[str, str]]:
|
|
123
|
+
"""Cheap local shape check before sending to the server.
|
|
124
|
+
|
|
125
|
+
Validates that top-level keys have the expected list-of-dicts shape and
|
|
126
|
+
that required identifying fields are present. Does NOT validate semantic
|
|
127
|
+
correctness (stat IDs existing, units matching, etc.) — that's surfaced
|
|
128
|
+
by the post-save server-side ``energy/validate`` call.
|
|
129
|
+
"""
|
|
130
|
+
errors: list[dict[str, str]] = []
|
|
131
|
+
|
|
132
|
+
if not isinstance(config, dict):
|
|
133
|
+
return [{"path": "config", "message": "must be a dict"}]
|
|
134
|
+
|
|
135
|
+
for key in _PREFS_TOP_LEVEL_KEYS:
|
|
136
|
+
if key not in config:
|
|
137
|
+
continue
|
|
138
|
+
value = config[key]
|
|
139
|
+
if not isinstance(value, list):
|
|
140
|
+
errors.append({"path": key, "message": "must be a list"})
|
|
141
|
+
continue
|
|
142
|
+
for idx, entry in enumerate(value):
|
|
143
|
+
if not isinstance(entry, dict):
|
|
144
|
+
errors.append(
|
|
145
|
+
{
|
|
146
|
+
"path": f"{key}[{idx}]",
|
|
147
|
+
"message": "entry must be a dict",
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
continue
|
|
151
|
+
if key == "energy_sources":
|
|
152
|
+
valid_types = {"grid", "solar", "battery", "gas"}
|
|
153
|
+
requires_stat_from = {"solar", "battery", "gas"}
|
|
154
|
+
entry_type = entry.get("type")
|
|
155
|
+
if entry_type is None:
|
|
156
|
+
errors.append(
|
|
157
|
+
{
|
|
158
|
+
"path": f"{key}[{idx}]",
|
|
159
|
+
"message": "energy_sources entries require 'type' (grid|solar|battery|gas)",
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
elif entry_type not in valid_types:
|
|
163
|
+
errors.append(
|
|
164
|
+
{
|
|
165
|
+
"path": f"{key}[{idx}].type",
|
|
166
|
+
"message": f"invalid type '{entry_type}' (must be one of grid|solar|battery|gas)",
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
elif (
|
|
170
|
+
entry_type in requires_stat_from and "stat_energy_from" not in entry
|
|
171
|
+
):
|
|
172
|
+
errors.append(
|
|
173
|
+
{
|
|
174
|
+
"path": f"{key}[{idx}]",
|
|
175
|
+
"message": f"{entry_type} entries require 'stat_energy_from'",
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
if key == "device_consumption" and "stat_consumption" not in entry:
|
|
179
|
+
errors.append(
|
|
180
|
+
{
|
|
181
|
+
"path": f"{key}[{idx}]",
|
|
182
|
+
"message": "device_consumption entries require 'stat_consumption'",
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
if key == "device_consumption_water" and "stat_consumption" not in entry:
|
|
186
|
+
errors.append(
|
|
187
|
+
{
|
|
188
|
+
"path": f"{key}[{idx}]",
|
|
189
|
+
"message": "device_consumption_water entries require 'stat_consumption'",
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return errors
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class EnergyTools:
|
|
197
|
+
"""Energy Dashboard preference management tools for Home Assistant."""
|
|
198
|
+
|
|
199
|
+
def __init__(self, client: Any) -> None:
|
|
200
|
+
self._client = client
|
|
201
|
+
|
|
202
|
+
@tool(
|
|
203
|
+
name="ha_manage_energy_prefs",
|
|
204
|
+
tags={"Energy"},
|
|
205
|
+
annotations={
|
|
206
|
+
"destructiveHint": True,
|
|
207
|
+
"idempotentHint": False,
|
|
208
|
+
"title": "Manage Energy Dashboard Preferences",
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
@log_tool_usage
|
|
212
|
+
async def ha_manage_energy_prefs(
|
|
213
|
+
self,
|
|
214
|
+
mode: Annotated[
|
|
215
|
+
Literal["get", "set"],
|
|
216
|
+
Field(
|
|
217
|
+
description="Operation mode: 'get' reads the current prefs; 'set' writes a new prefs payload."
|
|
218
|
+
),
|
|
219
|
+
],
|
|
220
|
+
config: Annotated[
|
|
221
|
+
dict[str, Any] | None,
|
|
222
|
+
Field(
|
|
223
|
+
description=(
|
|
224
|
+
"Full prefs payload for mode='set'. Must contain the "
|
|
225
|
+
"top-level keys you intend to replace: 'energy_sources', "
|
|
226
|
+
"'device_consumption', 'device_consumption_water'. Any "
|
|
227
|
+
"top-level key present in this payload REPLACES the "
|
|
228
|
+
"existing list entirely; any omitted key is preserved. "
|
|
229
|
+
"Call with mode='get' first, mutate the returned config, "
|
|
230
|
+
"then pass the whole object back."
|
|
231
|
+
),
|
|
232
|
+
default=None,
|
|
233
|
+
),
|
|
234
|
+
] = None,
|
|
235
|
+
config_hash: Annotated[
|
|
236
|
+
str | None,
|
|
237
|
+
Field(
|
|
238
|
+
description=(
|
|
239
|
+
"Hash returned by the previous mode='get' call. REQUIRED "
|
|
240
|
+
"for mode='set' unless dry_run=True. Rejected if the "
|
|
241
|
+
"server-side config has changed since that read — re-read "
|
|
242
|
+
"and retry."
|
|
243
|
+
),
|
|
244
|
+
default=None,
|
|
245
|
+
),
|
|
246
|
+
] = None,
|
|
247
|
+
dry_run: Annotated[
|
|
248
|
+
bool,
|
|
249
|
+
Field(
|
|
250
|
+
description=(
|
|
251
|
+
"For mode='set' only. If True, runs a local shape check "
|
|
252
|
+
"on the proposed config AND calls the server's "
|
|
253
|
+
"energy/validate against the CURRENT persisted state "
|
|
254
|
+
"(Home Assistant's validate endpoint cannot validate "
|
|
255
|
+
"an unsubmitted payload). Returns both error lists "
|
|
256
|
+
"without writing. Default False."
|
|
257
|
+
),
|
|
258
|
+
default=False,
|
|
259
|
+
),
|
|
260
|
+
] = False,
|
|
261
|
+
) -> dict[str, Any]:
|
|
262
|
+
"""
|
|
263
|
+
Manage the Home Assistant Energy Dashboard preferences.
|
|
264
|
+
|
|
265
|
+
The Energy Dashboard configuration (grid/solar/battery/gas sources,
|
|
266
|
+
individual device consumption sensors, cost tariffs, water) is stored
|
|
267
|
+
in ``.storage/energy`` and not otherwise reachable via REST, services,
|
|
268
|
+
or helper flows — this tool is the only way for agents to inspect or
|
|
269
|
+
modify it.
|
|
270
|
+
|
|
271
|
+
WHEN TO USE:
|
|
272
|
+
- To inspect or modify the Energy Dashboard config programmatically.
|
|
273
|
+
|
|
274
|
+
WHEN NOT TO USE:
|
|
275
|
+
- To create the underlying statistics themselves — they must already
|
|
276
|
+
exist as HA entities before being referenced here; create them via
|
|
277
|
+
the relevant integration's config flow first.
|
|
278
|
+
|
|
279
|
+
CAVEATS:
|
|
280
|
+
- ``energy/save_prefs`` has per-key FULL-REPLACE semantics. Passing
|
|
281
|
+
``{"device_consumption": [<one entry>]}`` deletes every other device
|
|
282
|
+
the user had configured — silently, with no error. Always call
|
|
283
|
+
mode='get' first, mutate the returned config, pass the whole object
|
|
284
|
+
back, and include the returned ``config_hash`` so the tool can
|
|
285
|
+
reject concurrent modifications.
|
|
286
|
+
- A local shape check runs before every write; malformed payloads
|
|
287
|
+
are rejected with a ``shape_errors`` list.
|
|
288
|
+
- After a successful write, the tool calls ``energy/validate`` and
|
|
289
|
+
returns any residual issues as ``post_save_validation_errors`` in
|
|
290
|
+
the response. These reflect semantic problems (missing stats, unit
|
|
291
|
+
mismatches) that shape checks can't catch; the save persists
|
|
292
|
+
regardless — correct the config and write again if needed.
|
|
293
|
+
- The underlying save endpoint is admin-only. Non-admin tokens will
|
|
294
|
+
receive an authorization error from Home Assistant.
|
|
295
|
+
"""
|
|
296
|
+
if mode == "get":
|
|
297
|
+
return await self._get_prefs()
|
|
298
|
+
|
|
299
|
+
# mode == "set"
|
|
300
|
+
if config is None:
|
|
301
|
+
raise_tool_error(
|
|
302
|
+
create_error_response(
|
|
303
|
+
ErrorCode.VALIDATION_MISSING_PARAMETER,
|
|
304
|
+
"'config' is required when mode='set'",
|
|
305
|
+
context={"mode": mode},
|
|
306
|
+
suggestions=[
|
|
307
|
+
"Call ha_manage_energy_prefs(mode='get') first, mutate the returned config, pass it back",
|
|
308
|
+
],
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
if dry_run:
|
|
313
|
+
return await self._dry_run(config)
|
|
314
|
+
|
|
315
|
+
if config_hash is None:
|
|
316
|
+
raise_tool_error(
|
|
317
|
+
create_error_response(
|
|
318
|
+
ErrorCode.VALIDATION_MISSING_PARAMETER,
|
|
319
|
+
"'config_hash' is required when mode='set' and dry_run=False",
|
|
320
|
+
context={"mode": mode},
|
|
321
|
+
suggestions=[
|
|
322
|
+
"Call ha_manage_energy_prefs(mode='get') to obtain a fresh config_hash",
|
|
323
|
+
"Or call again with dry_run=True to validate without a hash",
|
|
324
|
+
],
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
return await self._set_prefs(config, config_hash)
|
|
329
|
+
|
|
330
|
+
# ------------------------------------------------------------------
|
|
331
|
+
# Internal handlers
|
|
332
|
+
# ------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
async def _get_prefs(self) -> dict[str, Any]:
|
|
335
|
+
"""Fetch current prefs and return them with a config_hash.
|
|
336
|
+
|
|
337
|
+
On a Home Assistant instance that has never had the Energy Dashboard
|
|
338
|
+
configured, ``energy/get_prefs`` returns ``ERR_NOT_FOUND "No prefs"``
|
|
339
|
+
rather than an empty default. This method maps that case to the
|
|
340
|
+
documented default preferences structure so the tool works uniformly
|
|
341
|
+
on fresh installations.
|
|
342
|
+
"""
|
|
343
|
+
try:
|
|
344
|
+
result = await self._client.send_websocket_message(
|
|
345
|
+
{
|
|
346
|
+
"type": "energy/get_prefs",
|
|
347
|
+
}
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
if not result.get("success"):
|
|
351
|
+
error_msg = str(result.get("error", ""))
|
|
352
|
+
if _is_no_prefs_error(error_msg):
|
|
353
|
+
prefs = _default_prefs()
|
|
354
|
+
return {
|
|
355
|
+
"success": True,
|
|
356
|
+
"mode": "get",
|
|
357
|
+
"config": prefs,
|
|
358
|
+
"config_hash": compute_config_hash(prefs),
|
|
359
|
+
"note": (
|
|
360
|
+
"Energy Dashboard has never been configured on "
|
|
361
|
+
"this instance; returning empty default."
|
|
362
|
+
),
|
|
363
|
+
}
|
|
364
|
+
raise_tool_error(
|
|
365
|
+
create_error_response(
|
|
366
|
+
ErrorCode.SERVICE_CALL_FAILED,
|
|
367
|
+
f"Failed to get energy prefs: {result.get('error', 'Unknown error')}",
|
|
368
|
+
context={"mode": "get"},
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
prefs = result.get("result") or _default_prefs()
|
|
373
|
+
return {
|
|
374
|
+
"success": True,
|
|
375
|
+
"mode": "get",
|
|
376
|
+
"config": prefs,
|
|
377
|
+
"config_hash": compute_config_hash(prefs),
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
except ToolError:
|
|
381
|
+
raise
|
|
382
|
+
except Exception as e:
|
|
383
|
+
logger.error(f"Error getting energy prefs: {e}")
|
|
384
|
+
exception_to_structured_error(
|
|
385
|
+
e,
|
|
386
|
+
context={"mode": "get"},
|
|
387
|
+
suggestions=[
|
|
388
|
+
"Check Home Assistant connection",
|
|
389
|
+
"Verify WebSocket connection is active",
|
|
390
|
+
],
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
async def _dry_run(self, config: dict[str, Any]) -> dict[str, Any]:
|
|
394
|
+
"""Shape-check the proposed config and fetch current-state validate.
|
|
395
|
+
|
|
396
|
+
Returns both error lists clearly labelled so agents can distinguish
|
|
397
|
+
problems they're about to introduce (shape_errors) from pre-existing
|
|
398
|
+
issues in the persisted state (current_state_validation_errors).
|
|
399
|
+
"""
|
|
400
|
+
try:
|
|
401
|
+
shape_errors = _shape_check(config)
|
|
402
|
+
|
|
403
|
+
validate_result = await self._client.send_websocket_message(
|
|
404
|
+
{
|
|
405
|
+
"type": "energy/validate",
|
|
406
|
+
}
|
|
407
|
+
)
|
|
408
|
+
validate_warning: str | None = None
|
|
409
|
+
if validate_result.get("success"):
|
|
410
|
+
current_state_errors = _flatten_validation_errors(
|
|
411
|
+
validate_result.get("result", {})
|
|
412
|
+
)
|
|
413
|
+
else:
|
|
414
|
+
validate_error = validate_result.get("error") or "unknown error"
|
|
415
|
+
logger.warning(
|
|
416
|
+
f"energy/validate (current state) failed: {validate_error}"
|
|
417
|
+
)
|
|
418
|
+
current_state_errors = []
|
|
419
|
+
validate_warning = (
|
|
420
|
+
f"energy/validate failed: {validate_error} — "
|
|
421
|
+
"current-state validation skipped"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
response: dict[str, Any] = {
|
|
425
|
+
"success": len(shape_errors) == 0,
|
|
426
|
+
"mode": "set",
|
|
427
|
+
"dry_run": True,
|
|
428
|
+
"shape_errors": shape_errors,
|
|
429
|
+
"current_state_validation_errors": current_state_errors,
|
|
430
|
+
"message": (
|
|
431
|
+
"Shape OK. Note: HA's energy/validate cannot validate an "
|
|
432
|
+
"unsubmitted payload — current_state_validation_errors "
|
|
433
|
+
"reflects the CURRENT persisted config, not your proposal. "
|
|
434
|
+
"Semantic issues in the proposed config (missing stats, "
|
|
435
|
+
"wrong units) will surface in post_save_validation_errors "
|
|
436
|
+
"after an actual mode='set' write."
|
|
437
|
+
if not shape_errors
|
|
438
|
+
else f"{len(shape_errors)} shape error(s) — fix before writing."
|
|
439
|
+
),
|
|
440
|
+
}
|
|
441
|
+
if validate_warning is not None:
|
|
442
|
+
response["partial"] = True
|
|
443
|
+
response["warning"] = validate_warning
|
|
444
|
+
return response
|
|
445
|
+
|
|
446
|
+
except ToolError:
|
|
447
|
+
raise
|
|
448
|
+
except Exception as e:
|
|
449
|
+
logger.error(f"Error in energy prefs dry_run: {e}")
|
|
450
|
+
exception_to_structured_error(
|
|
451
|
+
e,
|
|
452
|
+
context={"mode": "set", "dry_run": True},
|
|
453
|
+
suggestions=[
|
|
454
|
+
"Check Home Assistant connection",
|
|
455
|
+
"Verify config shape matches energy/get_prefs response",
|
|
456
|
+
],
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
async def _set_prefs(
|
|
460
|
+
self,
|
|
461
|
+
config: dict[str, Any],
|
|
462
|
+
config_hash: str,
|
|
463
|
+
) -> dict[str, Any]:
|
|
464
|
+
"""Shape-check → hash-check → save → post-save validate.
|
|
465
|
+
|
|
466
|
+
Shape errors and hash mismatch fail closed. Post-save validation
|
|
467
|
+
errors are reported in the response as a non-fatal warning; the
|
|
468
|
+
save already succeeded.
|
|
469
|
+
"""
|
|
470
|
+
try:
|
|
471
|
+
# 1. Shape check (fast local, fail closed)
|
|
472
|
+
shape_errors = _shape_check(config)
|
|
473
|
+
if shape_errors:
|
|
474
|
+
raise_tool_error(
|
|
475
|
+
create_error_response(
|
|
476
|
+
ErrorCode.VALIDATION_FAILED,
|
|
477
|
+
f"Config shape invalid: {len(shape_errors)} error(s)",
|
|
478
|
+
context={
|
|
479
|
+
"mode": "set",
|
|
480
|
+
"shape_errors": shape_errors,
|
|
481
|
+
},
|
|
482
|
+
suggestions=[
|
|
483
|
+
"Fix the listed errors and retry",
|
|
484
|
+
"Call with dry_run=True to re-check without writing",
|
|
485
|
+
],
|
|
486
|
+
)
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
# 2. Fresh read for hash comparison. Map "No prefs" (never
|
|
490
|
+
# configured) to empty default so the hash-check works on
|
|
491
|
+
# fresh installations too.
|
|
492
|
+
current_result = await self._client.send_websocket_message(
|
|
493
|
+
{
|
|
494
|
+
"type": "energy/get_prefs",
|
|
495
|
+
}
|
|
496
|
+
)
|
|
497
|
+
if current_result.get("success"):
|
|
498
|
+
current_prefs: dict[str, Any] = (
|
|
499
|
+
current_result.get("result") or _default_prefs()
|
|
500
|
+
)
|
|
501
|
+
else:
|
|
502
|
+
error = current_result.get("error") or "Unknown error"
|
|
503
|
+
if _is_no_prefs_error(str(error)):
|
|
504
|
+
current_prefs = _default_prefs()
|
|
505
|
+
else:
|
|
506
|
+
raise_tool_error(
|
|
507
|
+
create_error_response(
|
|
508
|
+
ErrorCode.SERVICE_CALL_FAILED,
|
|
509
|
+
f"Failed to re-read prefs for hash check: {error}",
|
|
510
|
+
context={"mode": "set"},
|
|
511
|
+
)
|
|
512
|
+
)
|
|
513
|
+
# unreachable; appeases type checkers
|
|
514
|
+
current_prefs = {}
|
|
515
|
+
|
|
516
|
+
current_hash = compute_config_hash(current_prefs)
|
|
517
|
+
|
|
518
|
+
if current_hash != config_hash:
|
|
519
|
+
raise_tool_error(
|
|
520
|
+
create_error_response(
|
|
521
|
+
ErrorCode.RESOURCE_LOCKED,
|
|
522
|
+
"Energy prefs modified since last read (conflict)",
|
|
523
|
+
context={"mode": "set"},
|
|
524
|
+
suggestions=[
|
|
525
|
+
"Call ha_manage_energy_prefs(mode='get') again",
|
|
526
|
+
"Re-apply your changes to the fresh config",
|
|
527
|
+
"Pass the new config_hash back in",
|
|
528
|
+
],
|
|
529
|
+
)
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
# 3. Save
|
|
533
|
+
save_payload: dict[str, Any] = {"type": "energy/save_prefs"}
|
|
534
|
+
for key in _PREFS_TOP_LEVEL_KEYS:
|
|
535
|
+
if key in config:
|
|
536
|
+
save_payload[key] = config[key]
|
|
537
|
+
|
|
538
|
+
save_result = await self._client.send_websocket_message(save_payload)
|
|
539
|
+
if not save_result.get("success"):
|
|
540
|
+
raise_tool_error(
|
|
541
|
+
create_error_response(
|
|
542
|
+
ErrorCode.SERVICE_CALL_FAILED,
|
|
543
|
+
f"Failed to save energy prefs: {save_result.get('error', 'Unknown error')}",
|
|
544
|
+
context={"mode": "set"},
|
|
545
|
+
suggestions=[
|
|
546
|
+
"Verify the token has admin privileges (energy/save_prefs is admin-only)",
|
|
547
|
+
"Check config shape against the energy/get_prefs response",
|
|
548
|
+
],
|
|
549
|
+
)
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
# 4. Post-save validation against the newly-persisted state
|
|
553
|
+
post_save_errors: list[dict[str, str]] = []
|
|
554
|
+
post_save_validate_error: str | None = None
|
|
555
|
+
try:
|
|
556
|
+
validate_result = await self._client.send_websocket_message(
|
|
557
|
+
{
|
|
558
|
+
"type": "energy/validate",
|
|
559
|
+
}
|
|
560
|
+
)
|
|
561
|
+
if validate_result.get("success"):
|
|
562
|
+
post_save_errors = _flatten_validation_errors(
|
|
563
|
+
validate_result.get("result", {})
|
|
564
|
+
)
|
|
565
|
+
else:
|
|
566
|
+
post_save_validate_error = (
|
|
567
|
+
validate_result.get("error") or "unknown error"
|
|
568
|
+
)
|
|
569
|
+
logger.warning(
|
|
570
|
+
f"energy/validate (post-save) failed: {post_save_validate_error}"
|
|
571
|
+
)
|
|
572
|
+
except Exception as e:
|
|
573
|
+
# Post-save validate failure is non-fatal — the save itself
|
|
574
|
+
# succeeded. Log and continue.
|
|
575
|
+
logger.warning(f"Post-save energy/validate failed: {e}")
|
|
576
|
+
post_save_validate_error = str(e)
|
|
577
|
+
|
|
578
|
+
# 5. Compute new hash from the effective new state (current
|
|
579
|
+
# merged with the submitted keys; save_prefs does not echo it
|
|
580
|
+
# back).
|
|
581
|
+
new_prefs = {**current_prefs}
|
|
582
|
+
for key in _PREFS_TOP_LEVEL_KEYS:
|
|
583
|
+
if key in config:
|
|
584
|
+
new_prefs[key] = config[key]
|
|
585
|
+
new_hash = compute_config_hash(new_prefs)
|
|
586
|
+
|
|
587
|
+
response: dict[str, Any] = {
|
|
588
|
+
"success": True,
|
|
589
|
+
"mode": "set",
|
|
590
|
+
"config_hash": new_hash,
|
|
591
|
+
"message": "Energy prefs updated.",
|
|
592
|
+
}
|
|
593
|
+
if post_save_errors:
|
|
594
|
+
response["post_save_validation_errors"] = post_save_errors
|
|
595
|
+
response["warning"] = (
|
|
596
|
+
f"Save succeeded, but the persisted config has "
|
|
597
|
+
f"{len(post_save_errors)} validation error(s). Review "
|
|
598
|
+
"and re-write if any relate to this change."
|
|
599
|
+
)
|
|
600
|
+
elif post_save_validate_error is not None:
|
|
601
|
+
response["partial"] = True
|
|
602
|
+
response["warning"] = (
|
|
603
|
+
f"Save succeeded, but post-save energy/validate "
|
|
604
|
+
f"failed: {post_save_validate_error}. The persisted "
|
|
605
|
+
"config has not been re-validated."
|
|
606
|
+
)
|
|
607
|
+
return response
|
|
608
|
+
|
|
609
|
+
except ToolError:
|
|
610
|
+
raise
|
|
611
|
+
except Exception as e:
|
|
612
|
+
logger.error(f"Error setting energy prefs: {e}")
|
|
613
|
+
exception_to_structured_error(
|
|
614
|
+
e,
|
|
615
|
+
context={"mode": "set"},
|
|
616
|
+
suggestions=[
|
|
617
|
+
"Check Home Assistant connection",
|
|
618
|
+
"Verify token has admin privileges",
|
|
619
|
+
"Re-read prefs and retry with a fresh config_hash",
|
|
620
|
+
],
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def register_energy_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
625
|
+
"""Register Home Assistant energy preference management tools."""
|
|
626
|
+
register_tool_methods(mcp, EnergyTools(client))
|
|
@@ -245,10 +245,17 @@ class GroupTools:
|
|
|
245
245
|
] = True,
|
|
246
246
|
) -> dict[str, Any]:
|
|
247
247
|
"""
|
|
248
|
-
Create or update a Home Assistant entity group.
|
|
248
|
+
Create or update a service-based Home Assistant entity group via the group.set service.
|
|
249
249
|
|
|
250
|
-
|
|
251
|
-
|
|
250
|
+
**When NOT to use:** for typical "combine these entities into one controllable group"
|
|
251
|
+
requests, prefer `ha_config_set_helper(helper_type="group", ...)`. Config-entry-backed
|
|
252
|
+
groups are registered in the entity registry, so `ha_set_entity` can assign them to
|
|
253
|
+
areas and they are deletable via `ha_delete_config_entry`.
|
|
254
|
+
|
|
255
|
+
**When to use:** compatibility with existing groups already configured via group.set
|
|
256
|
+
or YAML, or the rare case where entity-registry membership is explicitly unwanted.
|
|
257
|
+
Groups created here are only removable via `ha_config_remove_group` — neither
|
|
258
|
+
`ha_config_remove_helper` nor `ha_delete_config_entry` will find them.
|
|
252
259
|
|
|
253
260
|
**For NEW groups:** Provide object_id and entities (required).
|
|
254
261
|
**For EXISTING groups:** Provide object_id and any fields to update.
|
|
@@ -333,9 +340,14 @@ class GroupTools:
|
|
|
333
340
|
] = True,
|
|
334
341
|
) -> dict[str, Any]:
|
|
335
342
|
"""
|
|
336
|
-
Remove a Home Assistant entity group.
|
|
343
|
+
Remove a service-based Home Assistant entity group via the group.remove service.
|
|
344
|
+
|
|
345
|
+
**When NOT to use:** for groups created through `ha_config_set_helper(helper_type="group", ...)`,
|
|
346
|
+
use `ha_delete_config_entry`. Those config-entry-backed groups are not reachable via the
|
|
347
|
+
group.remove service, and `ha_config_remove_helper` does not support helper_type="group".
|
|
337
348
|
|
|
338
|
-
|
|
349
|
+
**When to use:** removing groups created with `ha_config_set_group` or defined in YAML
|
|
350
|
+
via `group:` configuration. Config-entry-backed deletion tools cannot find these.
|
|
339
351
|
|
|
340
352
|
EXAMPLES:
|
|
341
353
|
- Remove group: ha_config_remove_group("living_room_lights")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ha-mcp-dev
|
|
3
|
-
Version: 7.3.0.
|
|
3
|
+
Version: 7.3.0.dev393
|
|
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
|
|
@@ -205,7 +205,7 @@ Spend less time configuring, more time enjoying your smart home.
|
|
|
205
205
|
| **Service & Device Control** | `ha_bulk_control`, `ha_call_service`, `ha_get_operation_status`, `ha_list_services` |
|
|
206
206
|
| **System** | `ha_backup_create`, `ha_backup_restore`, `ha_check_config`, `ha_config_set_yaml` *(beta)*, `ha_get_system_health`, `ha_get_updates`, `ha_reload_core`, `ha_restart` |
|
|
207
207
|
| **Todo Lists** | `ha_get_todo`, `ha_remove_todo_item`, `ha_set_todo_item` |
|
|
208
|
-
| **Utilities** | `ha_eval_template`, `ha_install_mcp_tools
|
|
208
|
+
| **Utilities** | `ha_eval_template`, `ha_install_mcp_tools` *(beta)*, `ha_report_issue` |
|
|
209
209
|
| **Zones** | `ha_get_zone`, `ha_remove_zone`, `ha_set_zone` |
|
|
210
210
|
|
|
211
211
|
<!-- TOOLS_TABLE_END -->
|
|
@@ -57,6 +57,7 @@ src/ha_mcp/tools/tools_config_dashboards.py
|
|
|
57
57
|
src/ha_mcp/tools/tools_config_entry_flow.py
|
|
58
58
|
src/ha_mcp/tools/tools_config_helpers.py
|
|
59
59
|
src/ha_mcp/tools/tools_config_scripts.py
|
|
60
|
+
src/ha_mcp/tools/tools_energy.py
|
|
60
61
|
src/ha_mcp/tools/tools_entities.py
|
|
61
62
|
src/ha_mcp/tools/tools_filesystem.py
|
|
62
63
|
src/ha_mcp/tools/tools_groups.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/AGENTS.md
RENAMED
|
File without changes
|
{ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/CLAUDE.md
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/LICENSE
RENAMED
|
File without changes
|
{ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/README.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/best_practice_checker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_config_automations.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_config_dashboards.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_config_entry_flow.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_config_helpers.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_config_scripts.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_voice_assistant.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/transforms/categorized_search.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp_dev.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp_dev.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|