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.
- {ha_mcp_dev-7.2.0.dev363/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.2.0.dev365}/PKG-INFO +1 -1
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/pyproject.toml +1 -1
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_config_automations.py +214 -7
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_config_dashboards.py +7 -14
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_config_scripts.py +198 -3
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_history.py +2 -2
- ha_mcp_dev-7.2.0.dev365/src/ha_mcp/utils/config_hash.py +18 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp_dev.egg-info/SOURCES.txt +1 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/LICENSE +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/README.md +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/setup.cfg +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/py.typed +0 -0
- {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
- {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
- {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
- {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
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/server.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/smart_search.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_categories.py +0 -0
- {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
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/utils/usage_logger.py +0 -0
- {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
- {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
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {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
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/tests/test_env_manager.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ha-mcp-dev"
|
|
7
|
-
version = "7.2.0.
|
|
7
|
+
version = "7.2.0.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"
|
{ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_config_automations.py
RENAMED
|
@@ -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
|
-
|
|
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'.
|
|
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.
|
|
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."""
|
{ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_config_dashboards.py
RENAMED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
{ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_config_scripts.py
RENAMED
|
@@ -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).
|
|
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]
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/AGENTS.md
RENAMED
|
File without changes
|
{ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/CLAUDE.md
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/LICENSE
RENAMED
|
File without changes
|
{ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/resources/skills-vendor/README.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/best_practice_checker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_config_entry_flow.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_config_helpers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/tools/tools_voice_assistant.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp/transforms/categorized_search.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp_dev.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{ha_mcp_dev-7.2.0.dev363 → ha_mcp_dev-7.2.0.dev365}/src/ha_mcp_dev.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|