ha-mcp-dev 7.2.0.dev363__tar.gz → 7.2.0.dev365__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 (101) hide show
  1. {ha_mcp_dev-7.2.0.dev363/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.2.0.dev365}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_config_automations.py +214 -7
  4. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_config_dashboards.py +7 -14
  5. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_config_scripts.py +198 -3
  6. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_history.py +2 -2
  7. ha_mcp_dev-7.2.0.dev365/src/ha_mcp/utils/config_hash.py +18 -0
  8. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  9. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp_dev.egg-info/SOURCES.txt +1 -0
  10. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/LICENSE +0 -0
  11. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/MANIFEST.in +0 -0
  12. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/README.md +0 -0
  13. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/setup.cfg +0 -0
  14. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/__init__.py +0 -0
  15. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/__main__.py +0 -0
  16. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/_pypi_marker +0 -0
  17. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/auth/__init__.py +0 -0
  18. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/auth/consent_form.py +0 -0
  19. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/auth/provider.py +0 -0
  20. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/client/__init__.py +0 -0
  21. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/client/rest_client.py +0 -0
  22. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/client/websocket_client.py +0 -0
  23. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/client/websocket_listener.py +0 -0
  24. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/config.py +0 -0
  25. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/errors.py +0 -0
  26. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/py.typed +0 -0
  27. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  28. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  29. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  30. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  31. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  32. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  33. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  34. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  35. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  36. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  37. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  38. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  39. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  40. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  41. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  42. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  43. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  44. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  45. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  46. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  47. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/server.py +0 -0
  48. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/smoke_test.py +0 -0
  49. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/__init__.py +0 -0
  50. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/backup.py +0 -0
  51. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  52. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/device_control.py +0 -0
  53. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/enhanced.py +0 -0
  54. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/helpers.py +0 -0
  55. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/registry.py +0 -0
  56. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/smart_search.py +0 -0
  57. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_addons.py +0 -0
  58. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_areas.py +0 -0
  59. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  60. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  61. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_calendar.py +0 -0
  62. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_camera.py +0 -0
  63. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_categories.py +0 -0
  64. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  65. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  66. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_entities.py +0 -0
  67. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  68. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_groups.py +0 -0
  69. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_hacs.py +0 -0
  70. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_integrations.py +0 -0
  71. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_labels.py +0 -0
  72. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  73. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_registry.py +0 -0
  74. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_resources.py +0 -0
  75. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_search.py +0 -0
  76. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_service.py +0 -0
  77. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_services.py +0 -0
  78. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_system.py +0 -0
  79. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_todo.py +0 -0
  80. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_traces.py +0 -0
  81. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_updates.py +0 -0
  82. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_utility.py +0 -0
  83. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  84. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  85. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_zones.py +0 -0
  86. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/util_helpers.py +0 -0
  87. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/transforms/__init__.py +0 -0
  88. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/transforms/categorized_search.py +0 -0
  89. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/utils/__init__.py +0 -0
  90. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/utils/domain_handlers.py +0 -0
  91. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  92. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/utils/operation_manager.py +0 -0
  93. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/utils/python_sandbox.py +0 -0
  94. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/utils/usage_logger.py +0 -0
  95. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  96. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  97. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  98. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  99. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/tests/__init__.py +0 -0
  100. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/tests/test_constants.py +0 -0
  101. {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/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.2.0.dev363
3
+ Version: 7.2.0.dev365
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.2.0.dev363"
7
+ version = "7.2.0.dev365"
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"
@@ -13,10 +13,18 @@ from fastmcp.tools import tool
13
13
  from pydantic import Field
14
14
 
15
15
  from ..errors import (
16
+ ErrorCode,
16
17
  create_config_error,
18
+ create_error_response,
17
19
  create_resource_not_found_error,
18
20
  create_validation_error,
19
21
  )
22
+ from ..utils.config_hash import compute_config_hash
23
+ from ..utils.python_sandbox import (
24
+ PythonSandboxError,
25
+ get_security_documentation,
26
+ safe_execute,
27
+ )
20
28
  from .best_practice_checker import (
21
29
  check_automation_config as _check_best_practices,
22
30
  )
@@ -244,11 +252,10 @@ class AutomationConfigTools:
244
252
  For comprehensive automation documentation, use ha_get_skill_home_assistant_best_practices.
245
253
  """
246
254
  try:
247
- config_result = await self._client.get_automation_config(identifier)
248
- # Normalize config for round-trip compatibility (GET -> SET)
249
- normalized_config = _normalize_config_for_roundtrip(config_result)
255
+ normalized_config, config_hash = await self._get_automation_config_internal(identifier)
250
256
 
251
257
  # Resolve entity_id and fetch category from entity registry
258
+ # (injected after hash so transient registry failures don't affect the hash)
252
259
  entity_id = await self._resolve_automation_entity_id(identifier)
253
260
  if entity_id:
254
261
  cat_id = await fetch_entity_category(self._client, entity_id, "automation")
@@ -260,6 +267,7 @@ class AutomationConfigTools:
260
267
  "action": "get",
261
268
  "identifier": identifier,
262
269
  "config": normalized_config,
270
+ "config_hash": config_hash,
263
271
  }
264
272
  except Exception as e:
265
273
  # Handle 404 errors gracefully (often used to verify deletion)
@@ -304,18 +312,44 @@ class AutomationConfigTools:
304
312
  async def ha_config_set_automation(
305
313
  self,
306
314
  config: Annotated[
307
- str | dict[str, Any],
315
+ str | dict[str, Any] | None,
308
316
  Field(
309
- description="Complete automation configuration with required fields: 'alias', 'trigger', 'action'. Optional: 'description', 'condition', 'mode', 'max', 'initial_state', 'variables'"
317
+ description="Complete automation configuration with required fields: 'alias', 'trigger', 'action'. "
318
+ "Optional: 'description', 'condition', 'mode', 'max', 'initial_state', 'variables'. "
319
+ "Mutually exclusive with python_transform.",
320
+ default=None,
310
321
  ),
311
- ],
322
+ ] = None,
312
323
  identifier: Annotated[
313
324
  str | None,
314
325
  Field(
315
- description="Automation entity_id or unique_id for updates. Omit to create new automation with generated unique_id.",
326
+ description="Automation entity_id or unique_id for updates. "
327
+ "Required for python_transform. Omit to create new automation with generated unique_id.",
316
328
  default=None,
317
329
  ),
318
330
  ] = None,
331
+ python_transform: Annotated[
332
+ str | None,
333
+ Field(
334
+ description="Python expression to transform existing automation config. "
335
+ "Mutually exclusive with config. "
336
+ "Requires identifier and config_hash for validation. "
337
+ "WARNING: Expressions with infinite loops will hang the server. "
338
+ "Examples: "
339
+ "Simple: python_transform=\"config['action'][0]['data']['brightness'] = 255\" "
340
+ "Pattern: python_transform=\"for a in config['action']: "
341
+ "if a.get('alias') == 'My Step': a['data']['value'] = 100\" "
342
+ "\n\n" + get_security_documentation(),
343
+ ),
344
+ ] = None,
345
+ config_hash: Annotated[
346
+ str | None,
347
+ Field(
348
+ description="Config hash from ha_config_get_automation for optimistic locking. "
349
+ "REQUIRED for python_transform (validates automation unchanged). "
350
+ "Optional for config updates (validates before full replacement if provided).",
351
+ ),
352
+ ] = None,
319
353
  category: Annotated[
320
354
  str | None,
321
355
  Field(
@@ -334,6 +368,19 @@ class AutomationConfigTools:
334
368
  """
335
369
  Create or update a Home Assistant automation.
336
370
 
371
+ Supports two modes: full config replacement OR Python transformation.
372
+
373
+ WHEN TO USE WHICH MODE:
374
+ - python_transform: RECOMMENDED for edits to existing automations. Surgical updates.
375
+ - config: Use for creating new automations or full restructures.
376
+
377
+ IMPORTANT: python_transform requires 'identifier' and 'config_hash' from ha_config_get_automation().
378
+
379
+ PYTHON TRANSFORM EXAMPLES:
380
+ - Update action: python_transform="config['action'][0]['data']['brightness'] = 255"
381
+ - Add trigger: python_transform="config['trigger'].append({'platform': 'state', 'entity_id': 'binary_sensor.motion', 'to': 'on'})"
382
+ - Remove last action: python_transform="config['action'].pop()"
383
+
337
384
  Creates a new automation (if identifier omitted) or updates existing automation with provided configuration.
338
385
 
339
386
  AUTOMATION TYPES:
@@ -452,6 +499,126 @@ class AutomationConfigTools:
452
499
  """
453
500
  bp_warnings: list[str] = []
454
501
  try:
502
+ # Validate mutual exclusivity of config and python_transform
503
+ if config is not None and python_transform is not None:
504
+ raise_tool_error(
505
+ create_error_response(
506
+ ErrorCode.VALIDATION_INVALID_PARAMETER,
507
+ "Cannot use both config and python_transform simultaneously",
508
+ suggestions=[
509
+ "Use only ONE of: config or python_transform",
510
+ "config: Full replacement",
511
+ "python_transform: Python-based edits (recommended for existing automations)",
512
+ ],
513
+ context={"action": "set", "identifier": identifier},
514
+ )
515
+ )
516
+
517
+ # Handle python_transform mode
518
+ if python_transform is not None:
519
+ if not identifier:
520
+ raise_tool_error(
521
+ create_error_response(
522
+ ErrorCode.VALIDATION_INVALID_PARAMETER,
523
+ "identifier is required for python_transform",
524
+ suggestions=[
525
+ "Provide the automation entity_id or unique_id",
526
+ "Use ha_search_entities(domain_filter='automation') to find automations",
527
+ ],
528
+ context={"action": "python_transform", "identifier": identifier},
529
+ )
530
+ )
531
+ if config_hash is None:
532
+ raise_tool_error(
533
+ create_error_response(
534
+ ErrorCode.VALIDATION_INVALID_PARAMETER,
535
+ "config_hash is required for python_transform",
536
+ suggestions=[
537
+ "Call ha_config_get_automation() first",
538
+ "Use the config_hash from that response",
539
+ ],
540
+ context={"action": "python_transform", "identifier": identifier},
541
+ )
542
+ )
543
+
544
+ # Fetch current config and verify hash
545
+ current_config = await self._fetch_and_verify_hash(
546
+ identifier, config_hash, "python_transform"
547
+ )
548
+
549
+ # Apply Python transformation
550
+ try:
551
+ transformed_config = safe_execute(python_transform, current_config)
552
+ except PythonSandboxError as e:
553
+ raise_tool_error(
554
+ create_error_response(
555
+ ErrorCode.VALIDATION_FAILED,
556
+ str(e),
557
+ suggestions=[
558
+ "Check expression syntax",
559
+ "Ensure only allowed operations are used",
560
+ "See tool description for allowed operations",
561
+ f"Expression: {python_transform[:100]}{'...' if len(python_transform) > 100 else ''}",
562
+ ],
563
+ context={"action": "python_transform", "identifier": identifier},
564
+ )
565
+ )
566
+
567
+ # Pop category before sending to HA REST API (rejects unknown keys)
568
+ transform_category = transformed_config.pop("category", None)
569
+
570
+ # Normalize and validate the transformed config
571
+ transformed_config = _normalize_automation_config(transformed_config)
572
+ self._validate_required_fields(transformed_config, identifier)
573
+ bp_warnings = _check_best_practices(
574
+ transformed_config, skill_prefix=_get_skill_prefix()
575
+ )
576
+
577
+ # Save transformed config
578
+ result = await self._client.upsert_automation_config(
579
+ transformed_config, identifier
580
+ )
581
+
582
+ # Re-fetch to get authoritative hash (HA may normalize after save)
583
+ refetched = await self._get_automation_config_internal(identifier)
584
+ new_config_hash = refetched[1] # (config, hash) tuple
585
+
586
+ # Re-apply category if present
587
+ entity_id = result.get("entity_id")
588
+ if not entity_id and identifier and identifier.startswith("automation."):
589
+ entity_id = identifier
590
+ if transform_category and entity_id:
591
+ await apply_entity_category(
592
+ self._client, entity_id, transform_category, "automation", result, "automation"
593
+ )
594
+
595
+ response: dict[str, Any] = {
596
+ "success": True,
597
+ "action": "python_transform",
598
+ "identifier": identifier,
599
+ "config_hash": new_config_hash,
600
+ "python_expression": python_transform,
601
+ "message": f"Automation {identifier} updated via Python transform",
602
+ # Merge upsert result, excluding "success" (we set it ourselves)
603
+ **{k: v for k, v in result.items() if k != "success"},
604
+ }
605
+ if bp_warnings:
606
+ response["best_practice_warnings"] = bp_warnings
607
+ return response
608
+
609
+ if config is None:
610
+ raise_tool_error(
611
+ create_error_response(
612
+ ErrorCode.VALIDATION_INVALID_PARAMETER,
613
+ "Either config or python_transform must be provided",
614
+ suggestions=[
615
+ "config: Full automation configuration for create/replace",
616
+ "python_transform: Python expression for surgical edits",
617
+ ],
618
+ context={"action": "set", "identifier": identifier},
619
+ )
620
+ )
621
+
455
622
  config_dict = self._parse_and_validate_config(config)
456
623
 
457
624
  # Extract category before sending to HA REST API (which rejects unknown keys).
@@ -462,6 +629,10 @@ class AutomationConfigTools:
462
629
  # Normalize field names (triggers -> trigger, actions -> action, etc.)
463
630
  config_dict = _normalize_automation_config(config_dict)
464
631
 
632
+ # Optional hash check for full config updates
633
+ if identifier and config_hash:
634
+ await self._fetch_and_verify_hash(identifier, config_hash, "set")
635
+
465
636
  # Validate required fields based on automation type
466
637
  self._validate_required_fields(config_dict, identifier)
467
638
 
@@ -533,6 +704,42 @@ class AutomationConfigTools:
533
704
  suggestions=suggestions,
534
705
  )
535
706
 
707
+ async def _get_automation_config_internal(
708
+ self, identifier: str
709
+ ) -> tuple[dict[str, Any], str]:
710
+ """Fetch and normalize automation config without logging or category injection.
711
+
712
+ Returns (normalized_config, config_hash) tuple.
713
+ Used internally by _fetch_and_verify_hash and ha_config_get_automation.
714
+ """
715
+ config_result = await self._client.get_automation_config(identifier)
716
+ normalized_config = _normalize_config_for_roundtrip(config_result)
717
+ config_hash_value = compute_config_hash(normalized_config)
718
+ return normalized_config, config_hash_value
719
+
720
+ async def _fetch_and_verify_hash(
721
+ self, identifier: str, config_hash: str, action: str
722
+ ) -> dict[str, Any]:
723
+ """Fetch current automation config and verify config_hash for optimistic locking.
724
+
725
+ Returns the current normalized config dict.
726
+ Raises ToolError if the hash does not match (conflict).
727
+ """
728
+ current_config, current_hash = await self._get_automation_config_internal(identifier)
729
+ if current_hash != config_hash:
730
+ raise_tool_error(
731
+ create_error_response(
732
+ ErrorCode.SERVICE_CALL_FAILED,
733
+ "Automation modified since last read (conflict)",
734
+ suggestions=[
735
+ "Call ha_config_get_automation() again",
736
+ "Use the fresh config_hash from that response",
737
+ ],
738
+ context={"action": action, "identifier": identifier},
739
+ )
740
+ )
741
+ return current_config
742
+
536
743
  @staticmethod
537
744
  def _parse_and_validate_config(config: str | dict[str, Any]) -> dict[str, Any]:
538
745
  """Parse JSON config and validate it is a dict."""
@@ -4,7 +4,6 @@ Configuration management tools for Home Assistant Lovelace dashboards.
4
4
  This module provides tools for managing dashboard metadata and content.
5
5
  """
6
6
 
7
- import hashlib
8
7
  import json
9
8
  import logging
10
9
  import re
@@ -14,6 +13,7 @@ from fastmcp.exceptions import ToolError
14
13
  from pydantic import Field
15
14
 
16
15
  from ..errors import ErrorCode, create_error_response, create_resource_not_found_error
16
+ from ..utils.config_hash import compute_config_hash
17
17
  from ..utils.python_sandbox import (
18
18
  PythonSandboxError,
19
19
  get_security_documentation,
@@ -26,13 +26,6 @@ logger = logging.getLogger(__name__)
26
26
 
27
27
 
28
28
 
29
- def _compute_config_hash(config: dict[str, Any]) -> str:
30
- """Compute a stable hash of dashboard config for optimistic locking."""
31
- # Use sorted keys for deterministic serialization
32
- config_str = json.dumps(config, sort_keys=True, separators=(",", ":"))
33
- return hashlib.sha256(config_str.encode()).hexdigest()[:16]
34
-
35
-
36
29
  async def _verify_config_unchanged(
37
30
  client: Any,
38
31
  url_path: str,
@@ -59,7 +52,7 @@ async def _verify_config_unchanged(
59
52
  if not isinstance(current_config, dict):
60
53
  return {"success": True} # Can't verify, proceed anyway
61
54
 
62
- current_hash = _compute_config_hash(current_config)
55
+ current_hash = compute_config_hash(current_config)
63
56
 
64
57
  if current_hash != original_hash:
65
58
  raise_tool_error(
@@ -433,7 +426,7 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
433
426
  for match in matches:
434
427
  del match["card_config"]
435
428
 
436
- config_hash: str | None = _compute_config_hash(config)
429
+ config_hash: str | None = compute_config_hash(config)
437
430
 
438
431
  return {
439
432
  "success": True,
@@ -486,7 +479,7 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
486
479
 
487
480
  # Compute hash for optimistic locking in subsequent operations
488
481
  config_hash = (
489
- _compute_config_hash(config) if isinstance(config, dict) else None
482
+ compute_config_hash(config) if isinstance(config, dict) else None
490
483
  )
491
484
 
492
485
  # Calculate config size for progressive disclosure hint
@@ -832,7 +825,7 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
832
825
  )
833
826
 
834
827
  # Validate config_hash for optimistic locking
835
- current_hash = _compute_config_hash(current_config)
828
+ current_hash = compute_config_hash(current_config)
836
829
  if current_hash != config_hash:
837
830
  raise_tool_error(
838
831
  create_error_response(
@@ -902,7 +895,7 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
902
895
  )
903
896
 
904
897
  # Compute new hash for potential chaining
905
- new_config_hash = _compute_config_hash(transformed_config)
898
+ new_config_hash = compute_config_hash(transformed_config)
906
899
 
907
900
  return {
908
901
  "success": True,
@@ -1072,7 +1065,7 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
1072
1065
 
1073
1066
  # Optional config_hash validation for full replacement
1074
1067
  if config_hash is not None:
1075
- current_hash = _compute_config_hash(current_config)
1068
+ current_hash = compute_config_hash(current_config)
1076
1069
  if current_hash != config_hash:
1077
1070
  raise_tool_error(
1078
1071
  create_error_response(
@@ -13,6 +13,12 @@ from fastmcp.tools import tool
13
13
  from pydantic import Field
14
14
 
15
15
  from ..errors import ErrorCode, create_error_response
16
+ from ..utils.config_hash import compute_config_hash
17
+ from ..utils.python_sandbox import (
18
+ PythonSandboxError,
19
+ get_security_documentation,
20
+ safe_execute,
21
+ )
16
22
  from .best_practice_checker import (
17
23
  check_script_config as _check_best_practices,
18
24
  )
@@ -95,8 +101,12 @@ class ConfigScriptTools:
95
101
  """
96
102
  try:
97
103
  config_result = await self._client.get_script_config(script_id)
104
+ # Extract actual script config body and compute hash before category injection
105
+ actual_config = config_result.get("config", config_result)
106
+ config_hash_value = compute_config_hash(actual_config)
98
107
 
99
108
  # Fetch category from entity registry (best-effort)
109
+ # (injected after hash so transient registry failures don't affect the hash)
100
110
  entity_id = f"script.{script_id}"
101
111
  cat_id = await fetch_entity_category(self._client, entity_id, "script")
102
112
  if cat_id:
@@ -107,6 +117,7 @@ class ConfigScriptTools:
107
117
  "action": "get",
108
118
  "script_id": script_id,
109
119
  "config": config_result,
120
+ "config_hash": config_hash_value,
110
121
  }
111
122
  except ToolError:
112
123
  raise
@@ -121,6 +132,43 @@ class ConfigScriptTools:
121
132
  ],
122
133
  )
123
134
 
135
+ async def _get_script_config_internal(
136
+ self, script_id: str
137
+ ) -> tuple[dict[str, Any], str]:
138
+ """Fetch script config without logging or category injection.
139
+
140
+ Returns (actual_config, config_hash) tuple where actual_config is
141
+ the inner script body (not the REST wrapper).
142
+ Used internally by _fetch_and_verify_hash and ha_config_get_script.
143
+ """
144
+ config_result = await self._client.get_script_config(script_id)
145
+ actual_config = config_result.get("config", config_result)
146
+ config_hash_value = compute_config_hash(actual_config)
147
+ return actual_config, config_hash_value
148
+
149
+ async def _fetch_and_verify_hash(
150
+ self, script_id: str, config_hash: str, action: str
151
+ ) -> dict[str, Any]:
152
+ """Fetch current script config and verify config_hash for optimistic locking.
153
+
154
+ Returns the actual script config dict (inner body).
155
+ Raises ToolError if the hash does not match (conflict).
156
+ """
157
+ actual_config, current_hash = await self._get_script_config_internal(script_id)
158
+ if current_hash != config_hash:
159
+ raise_tool_error(
160
+ create_error_response(
161
+ ErrorCode.SERVICE_CALL_FAILED,
162
+ "Script modified since last read (conflict)",
163
+ suggestions=[
164
+ "Call ha_config_get_script() again",
165
+ "Use the fresh config_hash from that response",
166
+ ],
167
+ context={"action": action, "script_id": script_id},
168
+ )
169
+ )
170
+ return actual_config
171
+
124
172
  @staticmethod
125
173
  def _validate_script_config(
126
174
  config: str | dict[str, Any],
@@ -187,11 +235,36 @@ class ConfigScriptTools:
187
235
  str, Field(description="Script identifier (e.g., 'morning_routine')")
188
236
  ],
189
237
  config: Annotated[
190
- str | dict[str, Any],
238
+ str | dict[str, Any] | None,
191
239
  Field(
192
- description="Script configuration dictionary. Must include EITHER 'sequence' (for regular scripts) OR 'use_blueprint' (for blueprint-based scripts). Optional fields: 'alias', 'description', 'icon', 'mode', 'max', 'fields'"
240
+ description="Script configuration dictionary. Must include EITHER 'sequence' (for regular scripts) OR 'use_blueprint' (for blueprint-based scripts). "
241
+ "Optional fields: 'alias', 'description', 'icon', 'mode', 'max', 'fields'. "
242
+ "Mutually exclusive with python_transform.",
243
+ default=None,
193
244
  ),
194
- ],
245
+ ] = None,
246
+ python_transform: Annotated[
247
+ str | None,
248
+ Field(
249
+ description="Python expression to transform existing script config. "
250
+ "Mutually exclusive with config. "
251
+ "Requires config_hash for validation. "
252
+ "WARNING: Expressions with infinite loops will hang the server. "
253
+ "Examples: "
254
+ "Simple: python_transform=\"config['sequence'][0]['data']['message'] = 'Hello'\" "
255
+ "Pattern: python_transform=\"for step in config['sequence']: "
256
+ "if step.get('alias') == 'My Step': step['data']['value'] = 100\" "
257
+ "\n\n" + get_security_documentation(),
258
+ ),
259
+ ] = None,
260
+ config_hash: Annotated[
261
+ str | None,
262
+ Field(
263
+ description="Config hash from ha_config_get_script for optimistic locking. "
264
+ "REQUIRED for python_transform (validates script unchanged). "
265
+ "Optional for config updates (validates before full replacement if provided).",
266
+ ),
267
+ ] = None,
195
268
  category: Annotated[
196
269
  str | None,
197
270
  Field(
@@ -210,6 +283,19 @@ class ConfigScriptTools:
210
283
  """
211
284
  Create or update a Home Assistant script.
212
285
 
286
+ Supports two modes: full config replacement OR Python transformation.
287
+
288
+ WHEN TO USE WHICH MODE:
289
+ - python_transform: RECOMMENDED for edits to existing scripts. Surgical updates.
290
+ - config: Use for creating new scripts or full restructures.
291
+
292
+ IMPORTANT: python_transform requires 'config_hash' from ha_config_get_script().
293
+
294
+ PYTHON TRANSFORM EXAMPLES:
295
+ - Update step: python_transform="config['sequence'][0]['data']['message'] = 'Hello'"
296
+ - Add step: python_transform="config['sequence'].append({'delay': {'seconds': 5}})"
297
+ - Remove last step: python_transform="config['sequence'].pop()"
298
+
213
299
  Creates a new script or updates an existing one with the provided configuration.
214
300
  Supports both regular scripts (with sequence) and blueprint-based scripts.
215
301
 
@@ -319,10 +405,119 @@ class ConfigScriptTools:
319
405
  """
320
406
  bp_warnings: list[str] = []
321
407
  try:
408
+ # Validate mutual exclusivity of config and python_transform
409
+ if config is not None and python_transform is not None:
410
+ raise_tool_error(
411
+ create_error_response(
412
+ ErrorCode.VALIDATION_INVALID_PARAMETER,
413
+ "Cannot use both config and python_transform simultaneously",
414
+ suggestions=[
415
+ "Use only ONE of: config or python_transform",
416
+ "config: Full replacement",
417
+ "python_transform: Python-based edits (recommended for existing scripts)",
418
+ ],
419
+ context={"action": "set", "script_id": script_id},
420
+ )
421
+ )
422
+
423
+ # Handle python_transform mode
424
+ if python_transform is not None:
425
+ if config_hash is None:
426
+ raise_tool_error(
427
+ create_error_response(
428
+ ErrorCode.VALIDATION_INVALID_PARAMETER,
429
+ "config_hash is required for python_transform",
430
+ suggestions=[
431
+ "Call ha_config_get_script() first",
432
+ "Use the config_hash from that response",
433
+ ],
434
+ context={"action": "python_transform", "script_id": script_id},
435
+ )
436
+ )
437
+
438
+ # Fetch current config and verify hash
439
+ actual_config = await self._fetch_and_verify_hash(
440
+ script_id, config_hash, "python_transform"
441
+ )
442
+
443
+ # Apply Python transformation on the actual script config
444
+ try:
445
+ transformed_config = safe_execute(python_transform, actual_config)
446
+ except PythonSandboxError as e:
447
+ raise_tool_error(
448
+ create_error_response(
449
+ ErrorCode.VALIDATION_FAILED,
450
+ str(e),
451
+ suggestions=[
452
+ "Check expression syntax",
453
+ "Ensure only allowed operations are used",
454
+ "See tool description for allowed operations",
455
+ f"Expression: {python_transform[:100]}{'...' if len(python_transform) > 100 else ''}",
456
+ ],
457
+ context={"action": "python_transform", "script_id": script_id},
458
+ )
459
+ )
460
+
461
+ # Validate transformed config
462
+ if "sequence" not in transformed_config and "use_blueprint" not in transformed_config:
463
+ raise_tool_error(
464
+ create_error_response(
465
+ ErrorCode.VALIDATION_FAILED,
466
+ "Transformed config must include either 'sequence' or 'use_blueprint'",
467
+ suggestions=[
468
+ "The transform may have removed required fields",
469
+ "Ensure the config still has a 'sequence' or 'use_blueprint' key",
470
+ ],
471
+ context={"action": "python_transform", "script_id": script_id},
472
+ )
473
+ )
474
+ bp_warnings = _check_best_practices(
475
+ transformed_config, skill_prefix=_get_skill_prefix()
476
+ )
477
+
478
+ # Save transformed config
479
+ result = await self._client.upsert_script_config(
480
+ transformed_config, script_id
481
+ )
482
+
483
+ # Re-fetch to get authoritative hash (HA may normalize after save)
484
+ _, new_config_hash = await self._get_script_config_internal(script_id)
485
+
486
+ response: dict[str, Any] = {
487
+ "success": True,
488
+ "action": "python_transform",
489
+ "script_id": script_id,
490
+ "config_hash": new_config_hash,
491
+ "python_expression": python_transform,
492
+ "message": f"Script {script_id} updated via Python transform",
493
+ # Merge upsert result, excluding "success" (we set it ourselves)
494
+ **{k: v for k, v in result.items() if k != "success"},
495
+ }
496
+ if bp_warnings:
497
+ response["best_practice_warnings"] = bp_warnings
498
+ return response
499
+
500
+ if config is None:
501
+ raise_tool_error(
502
+ create_error_response(
503
+ ErrorCode.VALIDATION_INVALID_PARAMETER,
504
+ "Either config or python_transform must be provided",
505
+ suggestions=[
506
+ "config: Full script configuration for create/replace",
507
+ "python_transform: Python expression for surgical edits",
508
+ ],
509
+ context={"action": "set", "script_id": script_id},
510
+ )
511
+ )
512
+
322
513
  config_dict, effective_category = self._validate_script_config(
323
514
  config, script_id, category,
324
515
  )
325
516
 
517
+ # Optional hash check for full config updates
518
+ if config_hash:
519
+ await self._fetch_and_verify_hash(script_id, config_hash, "set")
520
+
326
521
  # Pre-check for best-practice issues.
327
522
  bp_warnings = _check_best_practices(
328
523
  config_dict, skill_prefix=_get_skill_prefix()
@@ -194,7 +194,7 @@ class HistoryTools:
194
194
  period: Annotated[
195
195
  str,
196
196
  Field(
197
- description='Aggregation period: "5minute", "hour", "day", "week", "month". Default: "day". Ignored when source="history"',
197
+ description='Aggregation period: "5minute", "hour", "day", "week", "month", "year". Default: "day". Ignored when source="history"',
198
198
  default="day",
199
199
  ),
200
200
  ] = "day",
@@ -580,7 +580,7 @@ async def _fetch_statistics(
580
580
  ))
581
581
 
582
582
  # Validate period
583
- valid_periods = ["5minute", "hour", "day", "week", "month"]
583
+ valid_periods = ["5minute", "hour", "day", "week", "month", "year"]
584
584
  if period not in valid_periods:
585
585
  raise_tool_error(create_error_response(
586
586
  ErrorCode.VALIDATION_INVALID_PARAMETER,
@@ -0,0 +1,18 @@
1
+ """Shared config hash utility for optimistic locking.
2
+
3
+ Used by automation, script, and dashboard tools to detect concurrent modifications.
4
+ """
5
+
6
+ import hashlib
7
+ import json
8
+ from typing import Any
9
+
10
+
11
+ def compute_config_hash(config: dict[str, Any]) -> str:
12
+ """Compute a stable hash of a config dict for optimistic locking.
13
+
14
+ Uses SHA256 truncated to 16 hex characters (64 bits). Deterministic
15
+ via sorted keys and minimal separators.
16
+ """
17
+ config_str = json.dumps(config, sort_keys=True, separators=(",", ":"))
18
+ return hashlib.sha256(config_str.encode()).hexdigest()[:16]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.2.0.dev363
3
+ Version: 7.2.0.dev365
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
@@ -82,6 +82,7 @@ src/ha_mcp/tools/util_helpers.py
82
82
  src/ha_mcp/transforms/__init__.py
83
83
  src/ha_mcp/transforms/categorized_search.py
84
84
  src/ha_mcp/utils/__init__.py
85
+ src/ha_mcp/utils/config_hash.py
85
86
  src/ha_mcp/utils/domain_handlers.py
86
87
  src/ha_mcp/utils/fuzzy_search.py
87
88
  src/ha_mcp/utils/operation_manager.py