ha-mcp-dev 7.8.0.dev707__tar.gz → 7.8.0.dev709__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.8.0.dev707/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.8.0.dev709}/PKG-INFO +1 -1
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/pyproject.toml +2 -1
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/backup_manager.py +328 -33
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/backup.py +71 -1
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/LICENSE +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/README.md +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/setup.cfg +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/client/supervisor_client.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/dashboard_screenshot/__init__.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/dashboard_screenshot/capture.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/dashboard_screenshot/provision.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/policy/__init__.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/policy/approval_queue.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/policy/evaluator.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/policy/handlers.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/policy/middleware.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/policy/model.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/policy/persistence.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/policy/value_sources.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/read_only.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/server.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/settings.css +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/settings.js +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/settings_ui.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/stdio_settings_sidecar.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/auto_backup.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/config_entry_flow.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/smart_search/__init__.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/smart_search/_base.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/smart_search/_config.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/smart_search/_deep.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/smart_search/_entities.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/smart_search/_fetch.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/smart_search/_overview.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/smart_search/_scenes.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/smart_search/_scoring.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tool_search_hint_middleware.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_code.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_dashboard_screenshot.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_themes.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/validation_middleware.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/utils/data_paths.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/utils/skill_loader.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/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.8.0.
|
|
7
|
+
version = "7.8.0.dev709"
|
|
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"
|
|
@@ -187,6 +187,7 @@ asyncio_mode = "auto"
|
|
|
187
187
|
dev = [
|
|
188
188
|
"build>=1.2.2",
|
|
189
189
|
"docker>=7.1.0",
|
|
190
|
+
"httpx2>=2.4.0",
|
|
190
191
|
"mypy>=1.17.0",
|
|
191
192
|
"openai>=1.0.0",
|
|
192
193
|
"psutil>=7.0.0",
|
|
@@ -47,7 +47,7 @@ from collections.abc import Awaitable, Callable
|
|
|
47
47
|
from dataclasses import dataclass
|
|
48
48
|
from datetime import UTC, datetime
|
|
49
49
|
from pathlib import Path
|
|
50
|
-
from typing import Any
|
|
50
|
+
from typing import Any, TypedDict
|
|
51
51
|
|
|
52
52
|
import yaml # type: ignore[import-untyped]
|
|
53
53
|
from fastmcp.exceptions import ToolError
|
|
@@ -293,6 +293,14 @@ class BackupManager:
|
|
|
293
293
|
try:
|
|
294
294
|
config = await handler.fetch(self._client, entity_id)
|
|
295
295
|
except _CAPTURE_TRANSIENT_ERRORS as err:
|
|
296
|
+
# Degraded fetches (a non-list WS envelope from an
|
|
297
|
+
# auth-scope change or API drift) raise rather than return
|
|
298
|
+
# None — see ``_require_list``. During auto-backup we skip
|
|
299
|
+
# the snapshot with a WARNING (operator-visible) instead of
|
|
300
|
+
# crashing the pipeline; the same error during a diff/
|
|
301
|
+
# restore propagates to the tool layer as a structured
|
|
302
|
+
# error. The warning level (vs the debug log below) is what
|
|
303
|
+
# distinguishes "fetch broke" from "entity didn't exist".
|
|
296
304
|
logger.warning(
|
|
297
305
|
"Auto-backup: fetch failed for %s — %s: %s",
|
|
298
306
|
key,
|
|
@@ -522,7 +530,7 @@ class BackupManager:
|
|
|
522
530
|
async def restore_snapshot(
|
|
523
531
|
self, name: str, *, take_safety_backup: bool = True
|
|
524
532
|
) -> dict[str, Any]:
|
|
525
|
-
data = self.read_snapshot
|
|
533
|
+
data = await asyncio.to_thread(self.read_snapshot, name)
|
|
526
534
|
domain = data["domain"]
|
|
527
535
|
entity_id = data["entity_id"]
|
|
528
536
|
config = data["config"]
|
|
@@ -544,6 +552,254 @@ class BackupManager:
|
|
|
544
552
|
"result": result,
|
|
545
553
|
}
|
|
546
554
|
|
|
555
|
+
# ----- diff ----------------------------------------------------------
|
|
556
|
+
|
|
557
|
+
async def diff_snapshot(self, name: str) -> DiffResponse:
|
|
558
|
+
"""Compare a stored snapshot against the live config of the same entity.
|
|
559
|
+
|
|
560
|
+
Returns an RFC 6902-shaped JSON-Patch — the ops a client would
|
|
561
|
+
apply to ``current`` to recover ``stored`` (i.e. what
|
|
562
|
+
``restore_snapshot`` would functionally do). ``entity_missing``
|
|
563
|
+
flags the case where the entity is gone from HA, so the diff has
|
|
564
|
+
no live target to compare against; ``truncated`` flags that the
|
|
565
|
+
patch exceeded ``_MAX_PATCH_OPS`` and was cut short to keep the
|
|
566
|
+
tool response bounded.
|
|
567
|
+
|
|
568
|
+
``unchanged`` means the live config matches the snapshot — it is
|
|
569
|
+
``True`` only when the entity exists *and* the patch is empty.
|
|
570
|
+
Under ``entity_missing=True`` it is ``False``: there is no live
|
|
571
|
+
target to match, so "no action needed" would be wrong (the
|
|
572
|
+
empty patch is an artefact of the missing entity, not a match).
|
|
573
|
+
"""
|
|
574
|
+
data = await asyncio.to_thread(self.read_snapshot, name)
|
|
575
|
+
domain = data["domain"]
|
|
576
|
+
entity_id = data["entity_id"]
|
|
577
|
+
stored = data["config"]
|
|
578
|
+
handler = self._handlers.get(domain)
|
|
579
|
+
if handler is None:
|
|
580
|
+
raise LookupError(f"No diff handler registered for domain {domain!r}")
|
|
581
|
+
current = await handler.fetch(self._client, entity_id)
|
|
582
|
+
captured_at = data.get("captured")
|
|
583
|
+
if current is None:
|
|
584
|
+
return _build_diff_response(
|
|
585
|
+
name,
|
|
586
|
+
domain,
|
|
587
|
+
entity_id,
|
|
588
|
+
captured_at,
|
|
589
|
+
entity_missing=True,
|
|
590
|
+
patch=[],
|
|
591
|
+
counts=_summarize_patch_counts([]),
|
|
592
|
+
truncated=False,
|
|
593
|
+
)
|
|
594
|
+
patch: list[dict[str, Any]] = []
|
|
595
|
+
truncated = _compute_json_patch(stored, current, _MAX_PATCH_OPS, patch)
|
|
596
|
+
return _build_diff_response(
|
|
597
|
+
name,
|
|
598
|
+
domain,
|
|
599
|
+
entity_id,
|
|
600
|
+
captured_at,
|
|
601
|
+
entity_missing=False,
|
|
602
|
+
patch=patch,
|
|
603
|
+
counts=_summarize_patch_counts(patch),
|
|
604
|
+
truncated=truncated,
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
# --------------------------- diff helpers -----------------------------------
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
class DiffCounts(TypedDict):
|
|
612
|
+
"""Per-op-class tallies for a diff patch. ``total`` is the op count;
|
|
613
|
+
``add + remove + replace`` equals it today (see ``_summarize_patch_counts``)."""
|
|
614
|
+
|
|
615
|
+
add: int
|
|
616
|
+
remove: int
|
|
617
|
+
replace: int
|
|
618
|
+
total: int
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
class DiffResponse(TypedDict):
|
|
622
|
+
"""Return shape of ``BackupManager.diff_snapshot``. Both the
|
|
623
|
+
entity-present and ``entity_missing`` branches build this through
|
|
624
|
+
``_build_diff_response`` so the key set can't drift between them."""
|
|
625
|
+
|
|
626
|
+
kind: str
|
|
627
|
+
backup_name: str
|
|
628
|
+
domain: str
|
|
629
|
+
entity_id: str
|
|
630
|
+
captured_at: str | None
|
|
631
|
+
entity_missing: bool
|
|
632
|
+
patch: list[dict[str, Any]]
|
|
633
|
+
counts: DiffCounts
|
|
634
|
+
unchanged: bool
|
|
635
|
+
truncated: bool
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _build_diff_response(
|
|
639
|
+
name: str,
|
|
640
|
+
domain: str,
|
|
641
|
+
entity_id: str,
|
|
642
|
+
captured_at: str | None,
|
|
643
|
+
*,
|
|
644
|
+
entity_missing: bool,
|
|
645
|
+
patch: list[dict[str, Any]],
|
|
646
|
+
counts: DiffCounts,
|
|
647
|
+
truncated: bool,
|
|
648
|
+
) -> DiffResponse:
|
|
649
|
+
"""Assemble the diff return payload for either branch.
|
|
650
|
+
|
|
651
|
+
``unchanged`` means "live config matches the snapshot" — only true
|
|
652
|
+
when the entity exists and the patch is empty. Under
|
|
653
|
+
``entity_missing`` it is forced ``False``: the empty patch is an
|
|
654
|
+
artefact of the absent target, not evidence of a match.
|
|
655
|
+
"""
|
|
656
|
+
return {
|
|
657
|
+
"kind": "dict",
|
|
658
|
+
"backup_name": name,
|
|
659
|
+
"domain": domain,
|
|
660
|
+
"entity_id": entity_id,
|
|
661
|
+
"captured_at": captured_at,
|
|
662
|
+
"entity_missing": entity_missing,
|
|
663
|
+
"patch": patch,
|
|
664
|
+
"counts": counts,
|
|
665
|
+
"unchanged": not entity_missing and counts["total"] == 0,
|
|
666
|
+
"truncated": truncated,
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
# Output cap for diff_snapshot. Bounded payload keeps the tool response
|
|
671
|
+
# token-friendly even when the user diffs against a freshly-rewritten
|
|
672
|
+
# automation. Picked to comfortably cover typical edits (a handful of
|
|
673
|
+
# field changes) while still cutting off pathological cases like "I
|
|
674
|
+
# renamed every step of a 500-step script".
|
|
675
|
+
_MAX_PATCH_OPS = 200
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _compute_json_patch(
|
|
679
|
+
stored: Any, current: Any, max_ops: int, out: list[dict[str, Any]]
|
|
680
|
+
) -> bool:
|
|
681
|
+
"""Generate an RFC 6902 JSON-Patch from ``current`` to ``stored``.
|
|
682
|
+
|
|
683
|
+
The patch is the op sequence a client would apply to ``current`` to
|
|
684
|
+
recover ``stored`` (the captured snapshot is the target state).
|
|
685
|
+
Appends ops to ``out`` in place (capped at ``max_ops`` entries).
|
|
686
|
+
|
|
687
|
+
Returns True only when the diff genuinely exceeded ``max_ops``. The
|
|
688
|
+
generator collects one op beyond the cap so an exactly-full patch
|
|
689
|
+
(``len == max_ops``) isn't mistaken for a truncated one; the
|
|
690
|
+
overflow op is trimmed before returning.
|
|
691
|
+
"""
|
|
692
|
+
_diff_node(stored, current, "", out, max_ops + 1)
|
|
693
|
+
truncated = len(out) > max_ops
|
|
694
|
+
if truncated:
|
|
695
|
+
del out[max_ops:]
|
|
696
|
+
return truncated
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _diff_node(
|
|
700
|
+
stored: Any,
|
|
701
|
+
current: Any,
|
|
702
|
+
path: str,
|
|
703
|
+
out: list[dict[str, Any]],
|
|
704
|
+
max_ops: int,
|
|
705
|
+
) -> None:
|
|
706
|
+
if len(out) >= max_ops:
|
|
707
|
+
return
|
|
708
|
+
# ``type(s) is type(c)`` keeps ``True``/``1`` apart (both compare
|
|
709
|
+
# equal but represent different states for HA toggles); YAML loaders
|
|
710
|
+
# only emit plain dict/list/scalar containers, so subclass surprises
|
|
711
|
+
# aren't in scope.
|
|
712
|
+
if type(stored) is type(current):
|
|
713
|
+
if isinstance(stored, dict):
|
|
714
|
+
assert isinstance(current, dict)
|
|
715
|
+
for key in stored:
|
|
716
|
+
seg = _pointer_segment(str(key))
|
|
717
|
+
sub_path = f"{path}/{seg}"
|
|
718
|
+
if key not in current:
|
|
719
|
+
out.append({"op": "add", "path": sub_path, "value": stored[key]})
|
|
720
|
+
if len(out) >= max_ops:
|
|
721
|
+
return
|
|
722
|
+
else:
|
|
723
|
+
_diff_node(stored[key], current[key], sub_path, out, max_ops)
|
|
724
|
+
if len(out) >= max_ops:
|
|
725
|
+
return
|
|
726
|
+
for key in current:
|
|
727
|
+
if key not in stored:
|
|
728
|
+
seg = _pointer_segment(str(key))
|
|
729
|
+
out.append({"op": "remove", "path": f"{path}/{seg}"})
|
|
730
|
+
if len(out) >= max_ops:
|
|
731
|
+
return
|
|
732
|
+
return
|
|
733
|
+
if isinstance(stored, list):
|
|
734
|
+
assert isinstance(current, list)
|
|
735
|
+
min_len = min(len(stored), len(current))
|
|
736
|
+
for i in range(min_len):
|
|
737
|
+
_diff_node(stored[i], current[i], f"{path}/{i}", out, max_ops)
|
|
738
|
+
if len(out) >= max_ops:
|
|
739
|
+
return
|
|
740
|
+
if len(stored) > len(current):
|
|
741
|
+
for value in stored[len(current) :]:
|
|
742
|
+
out.append({"op": "add", "path": f"{path}/-", "value": value})
|
|
743
|
+
if len(out) >= max_ops:
|
|
744
|
+
return
|
|
745
|
+
elif len(current) > len(stored):
|
|
746
|
+
# Remove tail entries from highest to lowest index so
|
|
747
|
+
# successive removes stay valid (RFC 6902 reindexes
|
|
748
|
+
# after each op).
|
|
749
|
+
for i in range(len(current) - 1, len(stored) - 1, -1):
|
|
750
|
+
out.append({"op": "remove", "path": f"{path}/{i}"})
|
|
751
|
+
if len(out) >= max_ops:
|
|
752
|
+
return
|
|
753
|
+
return
|
|
754
|
+
if stored != current:
|
|
755
|
+
out.append({"op": "replace", "path": path or "", "value": stored})
|
|
756
|
+
return
|
|
757
|
+
# ``True == 1`` / ``False == 0`` in Python, so equality alone would
|
|
758
|
+
# let a bool/int type swap pass silently even though it represents
|
|
759
|
+
# a different state for HA toggles. The different-type branch
|
|
760
|
+
# forces a replace unconditionally. No post-append length guard here
|
|
761
|
+
# (unlike the loop sites above): this append is terminal, and
|
|
762
|
+
# ``_compute_json_patch`` budgets ``max_ops + 1`` precisely to absorb
|
|
763
|
+
# one final overflow op before trimming.
|
|
764
|
+
out.append({"op": "replace", "path": path or "", "value": stored})
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _pointer_segment(key: str) -> str:
|
|
768
|
+
"""Escape one JSON-Pointer reference token per RFC 6901 §3.
|
|
769
|
+
|
|
770
|
+
Order matters: ``~`` → ``~0`` must run before ``/`` → ``~1``. The
|
|
771
|
+
reverse order would first turn a literal ``/`` into ``~1``, and the
|
|
772
|
+
following ``~`` pass would then corrupt that fresh ``~1`` into
|
|
773
|
+
``~01``.
|
|
774
|
+
"""
|
|
775
|
+
return key.replace("~", "~0").replace("/", "~1")
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _summarize_patch_counts(patch: list[dict[str, Any]]) -> DiffCounts:
|
|
779
|
+
"""Tally op classes. ``add + remove + replace == total`` holds today
|
|
780
|
+
because ``_diff_node`` only emits those three ops; if a future change
|
|
781
|
+
starts emitting ``move``/``copy``/``test``, the class counts would sum
|
|
782
|
+
to less than ``total``. Warn on any unrecognized op so that drift is
|
|
783
|
+
visible instead of silently undercounting.
|
|
784
|
+
"""
|
|
785
|
+
classes: dict[str, int] = {"add": 0, "remove": 0, "replace": 0}
|
|
786
|
+
for op in patch:
|
|
787
|
+
op_type = op.get("op")
|
|
788
|
+
if isinstance(op_type, str) and op_type in classes:
|
|
789
|
+
classes[op_type] += 1
|
|
790
|
+
else:
|
|
791
|
+
logger.warning(
|
|
792
|
+
"diff: unrecognized JSON-Patch op %r — not reflected in "
|
|
793
|
+
"per-class counts (add/remove/replace)",
|
|
794
|
+
op_type,
|
|
795
|
+
)
|
|
796
|
+
return {
|
|
797
|
+
"add": classes["add"],
|
|
798
|
+
"remove": classes["remove"],
|
|
799
|
+
"replace": classes["replace"],
|
|
800
|
+
"total": len(patch),
|
|
801
|
+
}
|
|
802
|
+
|
|
547
803
|
|
|
548
804
|
# --------------------------- attach to client -------------------------------
|
|
549
805
|
|
|
@@ -651,8 +907,8 @@ async def _ws_send(client: Any, message: dict[str, Any]) -> Any:
|
|
|
651
907
|
# — unwrap so fetch / restore handlers downstream see the inner
|
|
652
908
|
# shape directly (list for ``<type>/list`` calls, dict for
|
|
653
909
|
# ``execute_script`` calls, etc.). Without the unwrap the
|
|
654
|
-
# ``
|
|
655
|
-
#
|
|
910
|
+
# ``_require_list`` checks in every fetch handler would see the
|
|
911
|
+
# envelope as a non-list and raise a spurious degraded-fetch error.
|
|
656
912
|
if isinstance(envelope, dict) and "result" in envelope:
|
|
657
913
|
return envelope["result"]
|
|
658
914
|
return envelope
|
|
@@ -764,13 +1020,52 @@ async def _restore_dashboard(client: Any, entity_id: str, config: Any) -> Any:
|
|
|
764
1020
|
)
|
|
765
1021
|
|
|
766
1022
|
|
|
1023
|
+
def _require_list(value: Any, endpoint: str) -> list[Any]:
|
|
1024
|
+
"""Return ``value`` if it's a list, else raise.
|
|
1025
|
+
|
|
1026
|
+
The WS registry-list fetchers below distinguish two cases that used
|
|
1027
|
+
to both collapse to ``None`` (which the diff/capture callers read as
|
|
1028
|
+
"entity missing"): a genuine miss (entity not in the list) stays
|
|
1029
|
+
``None``, but an unexpected non-list envelope — a degraded response
|
|
1030
|
+
from an auth-scope change or API drift — raises instead. The raise
|
|
1031
|
+
funnels through the diff tool's ``exception_to_structured_error`` and
|
|
1032
|
+
the capture pipeline's ``_CAPTURE_TRANSIENT_ERRORS`` warning, so a
|
|
1033
|
+
broken fetch is never reported as a confident ``entity_missing``.
|
|
1034
|
+
"""
|
|
1035
|
+
if not isinstance(value, list):
|
|
1036
|
+
raise HomeAssistantError(
|
|
1037
|
+
f"Expected a list from {endpoint!r}, got {type(value).__name__}"
|
|
1038
|
+
)
|
|
1039
|
+
return value
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def _require_dict(value: Any, endpoint: str) -> dict[str, Any]:
|
|
1043
|
+
"""Return ``value`` if it's a dict, else raise.
|
|
1044
|
+
|
|
1045
|
+
Dict-shaped counterpart to :func:`_require_list` for the
|
|
1046
|
+
``execute_script``-backed fetchers (calendar / todo). Their service
|
|
1047
|
+
response is a dict envelope; a non-dict body is a degraded/malformed
|
|
1048
|
+
200 (auth-scope change, API drift), not a genuine miss. Raising
|
|
1049
|
+
funnels it through the diff tool's ``exception_to_structured_error``
|
|
1050
|
+
and the capture pipeline's ``_CAPTURE_TRANSIENT_ERRORS`` warning,
|
|
1051
|
+
instead of collapsing to ``None`` — which callers read as
|
|
1052
|
+
``entity_missing``. The genuine-miss signal stays the nested ``uid``
|
|
1053
|
+
lookup returning ``None``.
|
|
1054
|
+
"""
|
|
1055
|
+
if not isinstance(value, dict):
|
|
1056
|
+
raise HomeAssistantError(
|
|
1057
|
+
f"Expected a dict from {endpoint!r}, got {type(value).__name__}"
|
|
1058
|
+
)
|
|
1059
|
+
return value
|
|
1060
|
+
|
|
1061
|
+
|
|
767
1062
|
# Dashboard resources — WS lovelace_resources commands.
|
|
768
1063
|
|
|
769
1064
|
|
|
770
1065
|
async def _fetch_dashboard_resource(client: Any, entity_id: str) -> Any:
|
|
771
|
-
resources =
|
|
772
|
-
|
|
773
|
-
|
|
1066
|
+
resources = _require_list(
|
|
1067
|
+
await _ws_send(client, {"type": "lovelace/resources"}), "lovelace/resources"
|
|
1068
|
+
)
|
|
774
1069
|
for res in resources:
|
|
775
1070
|
if str(res.get("id")) == entity_id:
|
|
776
1071
|
return res
|
|
@@ -808,9 +1103,10 @@ def _strip_readonly(config: dict[str, Any], *extra: str) -> dict[str, Any]:
|
|
|
808
1103
|
|
|
809
1104
|
|
|
810
1105
|
async def _fetch_label(client: Any, entity_id: str) -> Any:
|
|
811
|
-
items =
|
|
812
|
-
|
|
813
|
-
|
|
1106
|
+
items = _require_list(
|
|
1107
|
+
await _ws_send(client, {"type": "config/label_registry/list"}),
|
|
1108
|
+
"config/label_registry/list",
|
|
1109
|
+
)
|
|
814
1110
|
for item in items:
|
|
815
1111
|
if item.get("label_id") == entity_id:
|
|
816
1112
|
return item
|
|
@@ -831,11 +1127,12 @@ async def _fetch_category(client: Any, entity_id: str) -> Any:
|
|
|
831
1127
|
scope, _, cat_id = entity_id.partition(":")
|
|
832
1128
|
if not cat_id:
|
|
833
1129
|
return None
|
|
834
|
-
items =
|
|
835
|
-
|
|
1130
|
+
items = _require_list(
|
|
1131
|
+
await _ws_send(
|
|
1132
|
+
client, {"type": "config/category_registry/list", "scope": scope}
|
|
1133
|
+
),
|
|
1134
|
+
"config/category_registry/list",
|
|
836
1135
|
)
|
|
837
|
-
if not isinstance(items, list):
|
|
838
|
-
return None
|
|
839
1136
|
for item in items:
|
|
840
1137
|
if item.get("category_id") == cat_id:
|
|
841
1138
|
return {"scope": scope, **item}
|
|
@@ -925,8 +1222,7 @@ async def _fetch_calendar_event(client: Any, entity_id: str) -> Any:
|
|
|
925
1222
|
if getattr(err, "status_code", None) == 404:
|
|
926
1223
|
return None
|
|
927
1224
|
raise
|
|
928
|
-
|
|
929
|
-
return None
|
|
1225
|
+
result = _require_dict(result, "execute_script")
|
|
930
1226
|
events = result.get("response", {}).get("events", {}).get(cal, {}).get("events", [])
|
|
931
1227
|
for ev in events:
|
|
932
1228
|
if ev.get("uid") == uid:
|
|
@@ -951,9 +1247,7 @@ async def _restore_calendar_event(client: Any, entity_id: str, config: Any) -> A
|
|
|
951
1247
|
|
|
952
1248
|
|
|
953
1249
|
async def _fetch_zone(client: Any, entity_id: str) -> Any:
|
|
954
|
-
items = await _ws_send(client, {"type": "zone/list"})
|
|
955
|
-
if not isinstance(items, list):
|
|
956
|
-
return None
|
|
1250
|
+
items = _require_list(await _ws_send(client, {"type": "zone/list"}), "zone/list")
|
|
957
1251
|
for item in items:
|
|
958
1252
|
if item.get("id") == entity_id or item.get("name") == entity_id:
|
|
959
1253
|
return item
|
|
@@ -975,16 +1269,18 @@ async def _fetch_area_or_floor(client: Any, entity_id: str) -> Any:
|
|
|
975
1269
|
if not real_id:
|
|
976
1270
|
return None
|
|
977
1271
|
if kind == "area":
|
|
978
|
-
items =
|
|
979
|
-
|
|
980
|
-
|
|
1272
|
+
items = _require_list(
|
|
1273
|
+
await _ws_send(client, {"type": "config/area_registry/list"}),
|
|
1274
|
+
"config/area_registry/list",
|
|
1275
|
+
)
|
|
981
1276
|
for item in items:
|
|
982
1277
|
if item.get("area_id") == real_id:
|
|
983
1278
|
return {"kind": "area", **item}
|
|
984
1279
|
elif kind == "floor":
|
|
985
|
-
items =
|
|
986
|
-
|
|
987
|
-
|
|
1280
|
+
items = _require_list(
|
|
1281
|
+
await _ws_send(client, {"type": "config/floor_registry/list"}),
|
|
1282
|
+
"config/floor_registry/list",
|
|
1283
|
+
)
|
|
988
1284
|
for item in items:
|
|
989
1285
|
if item.get("floor_id") == real_id:
|
|
990
1286
|
return {"kind": "floor", **item}
|
|
@@ -1033,8 +1329,7 @@ async def _fetch_todo_item(client: Any, entity_id: str) -> Any:
|
|
|
1033
1329
|
if getattr(err, "status_code", None) == 404:
|
|
1034
1330
|
return None
|
|
1035
1331
|
raise
|
|
1036
|
-
|
|
1037
|
-
return None
|
|
1332
|
+
result = _require_dict(result, "execute_script")
|
|
1038
1333
|
items = result.get("response", {}).get("items", {}).get(cal, {}).get("items", [])
|
|
1039
1334
|
for item in items:
|
|
1040
1335
|
if item.get("uid") == uid:
|
|
@@ -1075,9 +1370,9 @@ async def _restore_entity_state(client: Any, entity_id: str, config: Any) -> Any
|
|
|
1075
1370
|
|
|
1076
1371
|
|
|
1077
1372
|
async def _fetch_integration(client: Any, entity_id: str) -> Any:
|
|
1078
|
-
items =
|
|
1079
|
-
|
|
1080
|
-
|
|
1373
|
+
items = _require_list(
|
|
1374
|
+
await _ws_send(client, {"type": "config_entries/get"}), "config_entries/get"
|
|
1375
|
+
)
|
|
1081
1376
|
for item in items:
|
|
1082
1377
|
if item.get("entry_id") == entity_id:
|
|
1083
1378
|
return item
|
|
@@ -1133,9 +1428,9 @@ async def _fetch_helper(client: Any, entity_id: str, helper_type: str) -> Any:
|
|
|
1133
1428
|
helper_type,
|
|
1134
1429
|
)
|
|
1135
1430
|
return None
|
|
1136
|
-
items =
|
|
1137
|
-
|
|
1138
|
-
|
|
1431
|
+
items = _require_list(
|
|
1432
|
+
await _ws_send(client, {"type": f"{helper_type}/list"}), f"{helper_type}/list"
|
|
1433
|
+
)
|
|
1139
1434
|
object_id = entity_id.split(".", 1)[-1] if "." in entity_id else entity_id
|
|
1140
1435
|
for item in items:
|
|
1141
1436
|
if item.get("id") == object_id or item.get("id") == entity_id:
|
|
@@ -855,6 +855,7 @@ _VALID_COMBOS: set[tuple[str, str]] = {
|
|
|
855
855
|
("edits", "create"),
|
|
856
856
|
("edits", "list"),
|
|
857
857
|
("edits", "view"),
|
|
858
|
+
("edits", "diff"),
|
|
858
859
|
("edits", "restore"),
|
|
859
860
|
("edits", "delete"),
|
|
860
861
|
}
|
|
@@ -920,6 +921,7 @@ def register_backup_tools(
|
|
|
920
921
|
| `edits` | `create` | On-demand snapshot of one entity (`domain` + `entity_id` required). Use before the user manually edits in the HA UI. Same handler path the decorator takes on writes; bypasses the `enable_auto_backup` toggle. |
|
|
921
922
|
| `edits` | `list` | List per-entity auto-backups (lightweight). Filter by `domain` and/or `entity_id`. |
|
|
922
923
|
| `edits` | `view` | Read one auto-backup file by name; returns YAML and parsed `config`. |
|
|
924
|
+
| `edits` | `diff` | Compare one auto-backup against the entity's current config. RFC 6902 JSON-Patch + add/remove/replace counts; bounded output. Read-only — fetches the live config, makes no changes. |
|
|
923
925
|
| `edits` | `restore` | Re-apply one auto-backup. Creates a fresh safety snapshot first. **No HA restart.** |
|
|
924
926
|
| `edits` | `delete` | Delete one auto-backup by `backup_name`, or bulk-delete by filter. |
|
|
925
927
|
|
|
@@ -939,6 +941,7 @@ def register_backup_tools(
|
|
|
939
941
|
- On-demand entity snapshot before a manual UI edit: `ha_manage_backup(scope="edits", action="create", domain="helper_input_boolean", entity_id="kitchen_lights_active")`
|
|
940
942
|
- List recent auto-backups for one automation: `ha_manage_backup(scope="edits", action="list", domain="automation", entity_id="kitchen_lights")`
|
|
941
943
|
- View an auto-backup: `ha_manage_backup(scope="edits", action="view", backup_name="automation.kitchen_lights.20260521_153000.yaml")`
|
|
944
|
+
- Diff an auto-backup vs current state: `ha_manage_backup(scope="edits", action="diff", backup_name="automation.kitchen_lights.20260521_153000.yaml")`
|
|
942
945
|
- Restore an auto-backup: `ha_manage_backup(scope="edits", action="restore", backup_name="automation.kitchen_lights.20260521_153000.yaml")`
|
|
943
946
|
- Delete one auto-backup: `ha_manage_backup(scope="edits", action="delete", backup_name="...")`
|
|
944
947
|
- Bulk-delete old auto-backups: `ha_manage_backup(scope="edits", action="delete", older_than_days=30)`
|
|
@@ -958,7 +961,7 @@ def register_backup_tools(
|
|
|
958
961
|
),
|
|
959
962
|
],
|
|
960
963
|
action: Annotated[
|
|
961
|
-
Literal["create", "restore", "list", "view", "delete"],
|
|
964
|
+
Literal["create", "restore", "list", "view", "diff", "delete"],
|
|
962
965
|
Field(
|
|
963
966
|
description="Operation to perform. Valid (scope, action) combinations are listed in the tool description."
|
|
964
967
|
),
|
|
@@ -1140,6 +1143,72 @@ def register_backup_tools(
|
|
|
1140
1143
|
)
|
|
1141
1144
|
return {"success": True, "data": data}
|
|
1142
1145
|
|
|
1146
|
+
if action == "diff":
|
|
1147
|
+
bname = _require("backup_name", backup_name, scope, action)
|
|
1148
|
+
try:
|
|
1149
|
+
diff = await mgr.diff_snapshot(bname)
|
|
1150
|
+
except FileNotFoundError:
|
|
1151
|
+
raise_tool_error(
|
|
1152
|
+
create_error_response(
|
|
1153
|
+
ErrorCode.RESOURCE_NOT_FOUND,
|
|
1154
|
+
f"Backup {bname!r} not found",
|
|
1155
|
+
context={"backup_name": bname},
|
|
1156
|
+
)
|
|
1157
|
+
)
|
|
1158
|
+
except (ValueError, LookupError) as err:
|
|
1159
|
+
raise_tool_error(
|
|
1160
|
+
create_error_response(
|
|
1161
|
+
ErrorCode.VALIDATION_INVALID_PARAMETER,
|
|
1162
|
+
str(err),
|
|
1163
|
+
context={"backup_name": bname},
|
|
1164
|
+
)
|
|
1165
|
+
)
|
|
1166
|
+
except ToolError:
|
|
1167
|
+
raise
|
|
1168
|
+
except Exception as err:
|
|
1169
|
+
# Fetching the live config for diff goes through the
|
|
1170
|
+
# same domain handler ``restore`` uses, so the same
|
|
1171
|
+
# HA-side failure modes (4xx/5xx, WS errors, schema
|
|
1172
|
+
# drift) apply. Funnel through
|
|
1173
|
+
# ``exception_to_structured_error`` so the structured
|
|
1174
|
+
# response carries enough context to retry.
|
|
1175
|
+
exception_to_structured_error(
|
|
1176
|
+
err,
|
|
1177
|
+
context={"backup_name": bname, "action": "diff"},
|
|
1178
|
+
suggestions=[
|
|
1179
|
+
"Verify the entity referenced by the backup still "
|
|
1180
|
+
+ "exists; diff fetches its current config",
|
|
1181
|
+
"Inspect the snapshot YAML via "
|
|
1182
|
+
+ "ha_manage_backup(scope='edits', action='view', "
|
|
1183
|
+
+ "backup_name=...) to confirm it parses",
|
|
1184
|
+
],
|
|
1185
|
+
)
|
|
1186
|
+
return None # unreachable: exception_to_structured_error always raises
|
|
1187
|
+
warnings: list[str] = []
|
|
1188
|
+
if diff.get("entity_missing"):
|
|
1189
|
+
# ``restore_snapshot`` outcome on a missing entity is
|
|
1190
|
+
# domain-dependent: upsert paths (automation, script,
|
|
1191
|
+
# dashboard) recreate it, but helper / label / category
|
|
1192
|
+
# restores go through ``<domain>/update`` WS commands
|
|
1193
|
+
# that expect the entity to exist and would surface a
|
|
1194
|
+
# WS error if it does not. Hedge rather than promise
|
|
1195
|
+
# one specific outcome.
|
|
1196
|
+
warnings.append(
|
|
1197
|
+
"Entity is missing from HA; restore behaviour is "
|
|
1198
|
+
"domain-dependent (upsert paths recreate it; "
|
|
1199
|
+
"update-only paths return an error)"
|
|
1200
|
+
)
|
|
1201
|
+
if diff.get("truncated"):
|
|
1202
|
+
warnings.append(
|
|
1203
|
+
"Patch truncated; entity has more changes than the bounded "
|
|
1204
|
+
"diff captures — view the snapshot for the full state"
|
|
1205
|
+
)
|
|
1206
|
+
return {
|
|
1207
|
+
"success": True,
|
|
1208
|
+
"data": diff,
|
|
1209
|
+
**({"warnings": warnings} if warnings else {}),
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1143
1212
|
if action == "restore":
|
|
1144
1213
|
bname = _require("backup_name", backup_name, scope, action)
|
|
1145
1214
|
try:
|
|
@@ -1185,6 +1254,7 @@ def register_backup_tools(
|
|
|
1185
1254
|
+ "backup_name=...)",
|
|
1186
1255
|
],
|
|
1187
1256
|
)
|
|
1257
|
+
return None # unreachable: exception_to_structured_error always raises
|
|
1188
1258
|
return {
|
|
1189
1259
|
"success": True,
|
|
1190
1260
|
"data": result,
|
|
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.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/dashboard_screenshot/__init__.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/dashboard_screenshot/capture.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/dashboard_screenshot/provision.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
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/AGENTS.md
RENAMED
|
File without changes
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/CLAUDE.md
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/resources/skills-vendor/LICENSE
RENAMED
|
File without changes
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/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
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/smart_search/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/smart_search/_config.py
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/smart_search/_entities.py
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/smart_search/_overview.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/smart_search/_scenes.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/smart_search/_scoring.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tool_search_hint_middleware.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
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_config_automations.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_config_dashboards.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_config_helpers.py
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_config_scripts.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_dashboard_screenshot.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/tools_voice_assistant.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/tools/validation_middleware.py
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/transforms/categorized_search.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/transforms/lite_docstrings.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp/utils/kill_signal_diagnostics.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/src/ha_mcp_dev.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{ha_mcp_dev-7.8.0.dev707 → ha_mcp_dev-7.8.0.dev709}/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
|