ha-mcp-dev 7.4.1.dev418__tar.gz → 7.4.1.dev420__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 (105) hide show
  1. {ha_mcp_dev-7.4.1.dev418/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev420}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_energy.py +177 -25
  4. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  5. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/LICENSE +0 -0
  6. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/MANIFEST.in +0 -0
  7. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/README.md +0 -0
  8. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/setup.cfg +0 -0
  9. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/__init__.py +0 -0
  10. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/__main__.py +0 -0
  11. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/_pypi_marker +0 -0
  12. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/_version.py +0 -0
  13. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/auth/__init__.py +0 -0
  14. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/auth/consent_form.py +0 -0
  15. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/auth/provider.py +0 -0
  16. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/client/__init__.py +0 -0
  17. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/client/rest_client.py +0 -0
  18. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/client/websocket_client.py +0 -0
  19. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/client/websocket_listener.py +0 -0
  20. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/config.py +0 -0
  21. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/errors.py +0 -0
  22. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/py.typed +0 -0
  23. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  24. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  25. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  26. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  27. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  28. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  29. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  30. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  31. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  32. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  33. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  34. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  35. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  36. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  37. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  38. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  39. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  40. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  41. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  42. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  43. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/server.py +0 -0
  44. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/settings_ui.py +0 -0
  45. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/smoke_test.py +0 -0
  46. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/__init__.py +0 -0
  47. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/backup.py +0 -0
  48. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  49. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/device_control.py +0 -0
  50. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/enhanced.py +0 -0
  51. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/helpers.py +0 -0
  52. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/reference_validator.py +0 -0
  53. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/registry.py +0 -0
  54. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/smart_search.py +0 -0
  55. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_addons.py +0 -0
  56. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_areas.py +0 -0
  57. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  58. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  59. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_calendar.py +0 -0
  60. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_camera.py +0 -0
  61. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_categories.py +0 -0
  62. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  63. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  64. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  65. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  66. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  67. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_entities.py +0 -0
  68. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  69. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_groups.py +0 -0
  70. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_hacs.py +0 -0
  71. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_history.py +0 -0
  72. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_integrations.py +0 -0
  73. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_labels.py +0 -0
  74. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  75. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_registry.py +0 -0
  76. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_resources.py +0 -0
  77. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_search.py +0 -0
  78. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_service.py +0 -0
  79. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_services.py +0 -0
  80. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_system.py +0 -0
  81. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_todo.py +0 -0
  82. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_traces.py +0 -0
  83. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_updates.py +0 -0
  84. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_utility.py +0 -0
  85. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  86. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  87. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/tools_zones.py +0 -0
  88. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/tools/util_helpers.py +0 -0
  89. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/transforms/__init__.py +0 -0
  90. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/transforms/categorized_search.py +0 -0
  91. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/utils/__init__.py +0 -0
  92. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/utils/config_hash.py +0 -0
  93. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/utils/domain_handlers.py +0 -0
  94. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  95. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/utils/operation_manager.py +0 -0
  96. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/utils/python_sandbox.py +0 -0
  97. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp/utils/usage_logger.py +0 -0
  98. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  99. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  100. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  101. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  102. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  103. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/tests/__init__.py +0 -0
  104. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/tests/test_constants.py +0 -0
  105. {ha_mcp_dev-7.4.1.dev418 → ha_mcp_dev-7.4.1.dev420}/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.4.1.dev418
3
+ Version: 7.4.1.dev420
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ha-mcp-dev"
7
- version = "7.4.1.dev418"
7
+ version = "7.4.1.dev420"
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"
@@ -28,7 +28,7 @@ get uniform behavior on fresh and configured instances alike.
28
28
  import json
29
29
  import logging
30
30
  from collections.abc import Callable
31
- from typing import Annotated, Any, Literal
31
+ from typing import Annotated, Any, Literal, cast
32
32
 
33
33
  from fastmcp.exceptions import ToolError
34
34
  from fastmcp.tools import tool
@@ -47,8 +47,14 @@ logger = logging.getLogger(__name__)
47
47
 
48
48
 
49
49
  # Top-level keys in the energy prefs payload. Each is an independent
50
- # full-replace slot in ``energy/save_prefs``.
51
- _PREFS_TOP_LEVEL_KEYS = (
50
+ # full-replace slot in ``energy/save_prefs``. ``_PrefsKey`` is the
51
+ # corresponding ``Literal`` alias so MCP-wire callers (Pydantic-validated)
52
+ # get typo-rejection at the boundary; runtime guards in `_set_prefs` cover
53
+ # the unit-test path that bypasses Pydantic.
54
+ _PrefsKey = Literal[
55
+ "energy_sources", "device_consumption", "device_consumption_water"
56
+ ]
57
+ _PREFS_TOP_LEVEL_KEYS: tuple[_PrefsKey, ...] = (
52
58
  "energy_sources",
53
59
  "device_consumption",
54
60
  "device_consumption_water",
@@ -73,6 +79,22 @@ def _default_prefs() -> dict[str, Any]:
73
79
  }
74
80
 
75
81
 
82
+ def _compute_per_key_hashes(prefs: dict[str, Any]) -> dict[_PrefsKey, str]:
83
+ """Per-top-level-key hashes for partial-update optimistic locking.
84
+
85
+ Each top-level key is wrapped in its own single-key dict before hashing,
86
+ so the per-key hash captures both the key name and its value — an agent
87
+ cannot accidentally use, say, an ``energy_sources`` hash to authorise a
88
+ ``device_consumption`` write. ``prefs.get(key, [])`` mirrors the
89
+ "missing top-level key = empty list" semantics codified by
90
+ ``_default_prefs``.
91
+ """
92
+ return {
93
+ key: compute_config_hash({key: prefs.get(key, [])})
94
+ for key in _PREFS_TOP_LEVEL_KEYS
95
+ }
96
+
97
+
76
98
  def _is_no_prefs_error(error_msg: str) -> bool:
77
99
  """True if an error string from send_websocket_message indicates
78
100
  ``ERR_NOT_FOUND "No prefs"`` from HA Core's energy/get_prefs handler.
@@ -243,14 +265,15 @@ class EnergyTools:
243
265
  ),
244
266
  ] = None,
245
267
  config_hash: Annotated[
246
- str | None,
268
+ str | dict[_PrefsKey, str] | None,
247
269
  Field(
248
270
  description=(
249
- "Hash returned by the previous mode='get' call. REQUIRED "
250
- "for mode='set' unless dry_run=True. Rejected if the "
251
- "server-side config has changed since that read re-read "
252
- "and retry. Ignored by convenience modes (they read fresh "
253
- "internally)."
271
+ "Hash from a previous mode='get' call. REQUIRED for "
272
+ "mode='set' unless dry_run=True. Two forms: str (full-"
273
+ "blob lock) or dict (per-key lock, taken from the "
274
+ "config_hash_per_key field of mode='get'). See the tool "
275
+ "docstring for fail-closed semantics. Ignored by "
276
+ "convenience modes."
254
277
  ),
255
278
  default=None,
256
279
  ),
@@ -375,6 +398,23 @@ class EnergyTools:
375
398
  the user had configured — silently, with no error. mode='set'
376
399
  requires a fresh ``config_hash`` for optimistic locking; convenience
377
400
  modes hide this entirely.
401
+ - ``config_hash`` accepts both a single ``str`` (full-blob lock) and
402
+ a ``dict[_PrefsKey, str]`` keyed by top-level keys (per-key lock,
403
+ taken from the ``config_hash_per_key`` field of the mode='get'
404
+ response). The per-key form lets an agent submit only the top-
405
+ level key it wants to change — set-equality between ``config``
406
+ keys and dict keys is enforced, and any key outside the canonical
407
+ set (typo, etc.) on either side is rejected with
408
+ ``VALIDATION_FAILED`` rather than silently dropped (so an empty
409
+ submission cannot succeed as a no-op). A per-key submission
410
+ still fully replaces that key's value as the save endpoint
411
+ requires. Mismatch on any locked key returns ``RESOURCE_LOCKED``
412
+ with the offending keys in the response's top-level
413
+ ``mismatched_keys`` (``create_error_response`` flattens the
414
+ ``context`` dict onto the response root).
415
+ - ``dry_run=True`` skips the hash check entirely for both forms;
416
+ the per-key form is therefore silently accepted on dry runs even
417
+ if its keys would mismatch the current state.
378
418
  - A local shape check runs before every write; malformed payloads
379
419
  are rejected with a ``shape_errors`` list.
380
420
  - After a successful write, the tool calls ``energy/validate`` and
@@ -481,6 +521,7 @@ class EnergyTools:
481
521
  "mode": "get",
482
522
  "config": prefs,
483
523
  "config_hash": compute_config_hash(prefs),
524
+ "config_hash_per_key": _compute_per_key_hashes(prefs),
484
525
  "note": (
485
526
  "Energy Dashboard has never been configured on "
486
527
  "this instance; returning empty default."
@@ -500,6 +541,7 @@ class EnergyTools:
500
541
  "mode": "get",
501
542
  "config": prefs,
502
543
  "config_hash": compute_config_hash(prefs),
544
+ "config_hash_per_key": _compute_per_key_hashes(prefs),
503
545
  }
504
546
 
505
547
  except ToolError:
@@ -584,7 +626,7 @@ class EnergyTools:
584
626
  async def _set_prefs(
585
627
  self,
586
628
  config: dict[str, Any],
587
- config_hash: str,
629
+ config_hash: str | dict[_PrefsKey, str],
588
630
  *,
589
631
  current_prefs: dict[str, Any] | None = None,
590
632
  ) -> dict[str, Any]:
@@ -594,12 +636,21 @@ class EnergyTools:
594
636
  errors are reported in the response as a non-fatal warning; the
595
637
  save already succeeded.
596
638
 
639
+ ``config_hash`` accepts two forms. A ``str`` locks against the
640
+ full prefs blob (the original optimistic-locking contract). A
641
+ ``dict[_PrefsKey, str]`` keyed by top-level keys locks each
642
+ submitted key individually — set-equality between ``config`` and
643
+ dict keys is enforced, and unknown keys on either side are
644
+ rejected (``VALIDATION_FAILED``) so an empty submission cannot
645
+ coincide as a no-op success. See the tool docstring for the full
646
+ agent-facing contract.
647
+
597
648
  ``current_prefs`` is an optional caller-supplied snapshot. When
598
649
  provided, the internal re-read is skipped — the convenience-mode
599
650
  path uses this to avoid a second ``energy/get_prefs`` round trip
600
651
  per attempt (the snapshot was already fetched by ``_mutate_atomic``).
601
- The hash check still runs against the provided snapshot as a
602
- defensive guard.
652
+ Convenience modes always pass a ``str`` hash; the dict form is
653
+ only reachable via direct mode='set' callers.
603
654
  """
604
655
  try:
605
656
  # 1. Shape check (fast local, fail closed)
@@ -648,21 +699,121 @@ class EnergyTools:
648
699
  # unreachable; appeases type checkers
649
700
  current_prefs = {}
650
701
 
651
- current_hash = compute_config_hash(current_prefs)
702
+ if isinstance(config_hash, dict):
703
+ # Per-key form: validate (and hence allow saving) only the
704
+ # top-level keys whose hashes were supplied. Fail-closed on
705
+ # unknown keys (no silent-drop) so a typo in both 'config'
706
+ # and 'config_hash' cannot coincide as an empty no-op
707
+ # success at the save endpoint.
708
+ _valid = set(_PREFS_TOP_LEVEL_KEYS)
709
+ invalid_config_keys = sorted(set(config) - _valid)
710
+ invalid_hash_keys = sorted(set(config_hash) - _valid)
711
+ if invalid_config_keys or invalid_hash_keys:
712
+ raise_tool_error(
713
+ create_error_response(
714
+ ErrorCode.VALIDATION_FAILED,
715
+ "Unknown top-level key(s) in 'config' or "
716
+ "'config_hash' (per-key form)",
717
+ context={
718
+ "mode": "set",
719
+ "invalid_config_keys": invalid_config_keys,
720
+ "invalid_hash_keys": invalid_hash_keys,
721
+ "valid_keys": list(_PREFS_TOP_LEVEL_KEYS),
722
+ },
723
+ suggestions=[
724
+ "Use only 'energy_sources', "
725
+ "'device_consumption', or "
726
+ "'device_consumption_water'",
727
+ ],
728
+ )
729
+ )
652
730
 
653
- if current_hash != config_hash:
654
- raise_tool_error(
655
- create_error_response(
656
- ErrorCode.RESOURCE_LOCKED,
657
- "Energy prefs modified since last read (conflict)",
658
- context={"mode": "set"},
659
- suggestions=[
660
- "Call ha_manage_energy_prefs(mode='get') again",
661
- "Re-apply your changes to the fresh config",
662
- "Pass the new config_hash back in",
663
- ],
731
+ submitted_keys = set(config)
732
+ hashed_keys = set(config_hash)
733
+ if not submitted_keys:
734
+ raise_tool_error(
735
+ create_error_response(
736
+ ErrorCode.VALIDATION_FAILED,
737
+ "'config' must include at least one top-level "
738
+ "key when using per-key config_hash",
739
+ context={"mode": "set"},
740
+ suggestions=[
741
+ "Include the top-level key(s) you want to "
742
+ "save in 'config' alongside their per-key "
743
+ "hashes in 'config_hash'",
744
+ ],
745
+ )
746
+ )
747
+ if submitted_keys != hashed_keys:
748
+ raise_tool_error(
749
+ create_error_response(
750
+ ErrorCode.VALIDATION_FAILED,
751
+ "Per-key config_hash keys must match the "
752
+ "top-level keys submitted in 'config'",
753
+ context={
754
+ "mode": "set",
755
+ "submitted_keys": sorted(submitted_keys),
756
+ "hashed_keys": sorted(hashed_keys),
757
+ "missing_in_hash": sorted(
758
+ submitted_keys - hashed_keys
759
+ ),
760
+ "extra_in_hash": sorted(
761
+ hashed_keys - submitted_keys
762
+ ),
763
+ },
764
+ suggestions=[
765
+ "Pass exactly one config_hash_per_key entry "
766
+ "per top-level key in 'config'",
767
+ "Use the str form of config_hash to lock "
768
+ "the full prefs blob instead",
769
+ ],
770
+ )
771
+ )
772
+
773
+ # ``submitted_keys`` was validated against
774
+ # ``_PREFS_TOP_LEVEL_KEYS`` above, so each ``key`` is in
775
+ # fact a ``_PrefsKey``. Mypy can't narrow ``str`` from
776
+ # ``sorted(set[str])`` automatically, hence the explicit
777
+ # ``cast`` at the dict subscript.
778
+ mismatched_keys = [
779
+ key
780
+ for key in sorted(submitted_keys)
781
+ if config_hash[cast(_PrefsKey, key)]
782
+ != compute_config_hash({key: current_prefs.get(key, [])})
783
+ ]
784
+ if mismatched_keys:
785
+ raise_tool_error(
786
+ create_error_response(
787
+ ErrorCode.RESOURCE_LOCKED,
788
+ "Energy prefs modified since last read on "
789
+ f"top-level key(s): {', '.join(mismatched_keys)}"
790
+ " (conflict)",
791
+ context={
792
+ "mode": "set",
793
+ "mismatched_keys": mismatched_keys,
794
+ },
795
+ suggestions=[
796
+ "Call ha_manage_energy_prefs(mode='get') again",
797
+ "Re-apply your changes to the fresh config",
798
+ "Pass the new config_hash_per_key back in",
799
+ ],
800
+ )
801
+ )
802
+ else:
803
+ current_hash = compute_config_hash(current_prefs)
804
+ if current_hash != config_hash:
805
+ raise_tool_error(
806
+ create_error_response(
807
+ ErrorCode.RESOURCE_LOCKED,
808
+ "Energy prefs modified since last read (conflict)",
809
+ context={"mode": "set"},
810
+ suggestions=[
811
+ "Call ha_manage_energy_prefs(mode='get') again",
812
+ "Re-apply your changes to the fresh config",
813
+ "Pass the new config_hash back in",
814
+ ],
815
+ )
664
816
  )
665
- )
666
817
 
667
818
  # 3. Save
668
819
  save_payload: dict[str, Any] = {"type": "energy/save_prefs"}
@@ -723,6 +874,7 @@ class EnergyTools:
723
874
  "success": True,
724
875
  "mode": "set",
725
876
  "config_hash": new_hash,
877
+ "config_hash_per_key": _compute_per_key_hashes(new_prefs),
726
878
  "message": "Energy prefs updated.",
727
879
  }
728
880
  if post_save_errors:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.4.1.dev418
3
+ Version: 7.4.1.dev420
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