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.
Files changed (102) hide show
  1. {ha_mcp_dev-7.3.0.dev391/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.3.0.dev393}/PKG-INFO +2 -2
  2. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/README.md +1 -1
  3. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/pyproject.toml +1 -1
  4. ha_mcp_dev-7.3.0.dev393/src/ha_mcp/tools/tools_energy.py +626 -0
  5. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_groups.py +17 -5
  6. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393/src/ha_mcp_dev.egg-info}/PKG-INFO +2 -2
  7. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp_dev.egg-info/SOURCES.txt +1 -0
  8. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/LICENSE +0 -0
  9. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/MANIFEST.in +0 -0
  10. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/setup.cfg +0 -0
  11. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/__init__.py +0 -0
  12. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/__main__.py +0 -0
  13. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/_pypi_marker +0 -0
  14. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/auth/__init__.py +0 -0
  15. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/auth/consent_form.py +0 -0
  16. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/auth/provider.py +0 -0
  17. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/client/__init__.py +0 -0
  18. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/client/rest_client.py +0 -0
  19. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/client/websocket_client.py +0 -0
  20. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/client/websocket_listener.py +0 -0
  21. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/config.py +0 -0
  22. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/errors.py +0 -0
  23. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/py.typed +0 -0
  24. {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
  25. {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
  26. {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
  27. {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
  28. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  29. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  30. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  31. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  32. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  33. {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
  34. {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
  35. {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
  36. {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
  37. {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
  38. {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
  39. {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
  40. {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
  41. {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
  42. {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
  43. {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
  44. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/server.py +0 -0
  45. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/smoke_test.py +0 -0
  46. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/__init__.py +0 -0
  47. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/backup.py +0 -0
  48. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  49. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/device_control.py +0 -0
  50. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/enhanced.py +0 -0
  51. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/helpers.py +0 -0
  52. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/registry.py +0 -0
  53. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/smart_search.py +0 -0
  54. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_addons.py +0 -0
  55. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_areas.py +0 -0
  56. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  57. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  58. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_calendar.py +0 -0
  59. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_camera.py +0 -0
  60. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_categories.py +0 -0
  61. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  62. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  63. {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
  64. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  65. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  66. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_entities.py +0 -0
  67. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  68. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_hacs.py +0 -0
  69. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_history.py +0 -0
  70. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_integrations.py +0 -0
  71. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_labels.py +0 -0
  72. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  73. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_registry.py +0 -0
  74. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_resources.py +0 -0
  75. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_search.py +0 -0
  76. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_service.py +0 -0
  77. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_services.py +0 -0
  78. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_system.py +0 -0
  79. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_todo.py +0 -0
  80. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_traces.py +0 -0
  81. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_updates.py +0 -0
  82. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_utility.py +0 -0
  83. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  84. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  85. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/tools_zones.py +0 -0
  86. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/tools/util_helpers.py +0 -0
  87. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/transforms/__init__.py +0 -0
  88. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/transforms/categorized_search.py +0 -0
  89. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/utils/__init__.py +0 -0
  90. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/utils/config_hash.py +0 -0
  91. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/utils/domain_handlers.py +0 -0
  92. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  93. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/utils/operation_manager.py +0 -0
  94. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/utils/python_sandbox.py +0 -0
  95. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp/utils/usage_logger.py +0 -0
  96. {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
  97. {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
  98. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  99. {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
  100. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/tests/__init__.py +0 -0
  101. {ha_mcp_dev-7.3.0.dev391 → ha_mcp_dev-7.3.0.dev393}/tests/test_constants.py +0 -0
  102. {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.dev391
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`, `ha_report_issue` |
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`, `ha_report_issue` |
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.dev391"
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
- Uses the group.set service to create a new group or update an existing one.
251
- Groups are useful for organizing entities and controlling them together.
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
- Uses the group.remove service to delete the group.
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.dev391
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`, `ha_report_issue` |
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