ha-mcp-dev 7.12.3.dev1942__tar.gz → 7.12.3.dev1949__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.12.3.dev1942/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.12.3.dev1949}/PKG-INFO +1 -1
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/pyproject.toml +1 -1
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/config.py +31 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/settings_ui/_handlers_backups.py +1 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/settings_ui/settings.js +9 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/backup.py +349 -13
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/LICENSE +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/README.md +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/setup.cfg +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/backup_manager.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/browser_landing.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/client/supervisor_client.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/dashboard_screenshot/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/dashboard_screenshot/capture.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/dashboard_screenshot/content.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/dashboard_screenshot/paths.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/dashboard_screenshot/provision.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/llm_exposure.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/policy/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/policy/approval_queue.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/policy/evaluator.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/policy/handlers.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/policy/middleware.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/policy/model.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/policy/persistence.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/policy/value_sources.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/read_only.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/appdaemon.md +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/blueprint-guide.md +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/scenes.md +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/server.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/settings_ui/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/settings_ui/_handlers_advanced.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/settings_ui/_handlers_fs.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/settings_ui/_handlers_server.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/settings_ui/_handlers_theme.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/settings_ui/_handlers_tools.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/settings_ui/_persistence.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/settings_ui/_supervisor.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/settings_ui/_theme.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/settings_ui/_tools_meta.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/settings_ui/settings.css +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/settings_ui/settings.html +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/stdio_settings_sidecar.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/strict_bps.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/auto_backup.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/component_api.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/config_entry_flow.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/radio/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/radio/base.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/radio/matter.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/radio/thread.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/radio/zigbee.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/radio/zwave.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/smart_search/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/smart_search/_base.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/smart_search/_config.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/smart_search/_deep.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/smart_search/_entities.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/smart_search/_fetch.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/smart_search/_overview.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/smart_search/_scenes.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/smart_search/_scoring.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tool_search_hint_middleware.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_code.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_dashboard_screenshot.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_dev.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_radio.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_themes.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_yaml_read.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/tools/validation_middleware.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/transport_security.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/update_check.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/utils/data_paths.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/utils/skill_loader.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/visibility/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/visibility/model.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/visibility/persistence.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/visibility/resolver.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/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.12.3.
|
|
7
|
+
version = "7.12.3.dev1949"
|
|
8
8
|
description = "Home Assistant MCP Server - Complete control of Home Assistant through MCP"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
# Upper bound widened to <3.15 so the package installs on Home Assistant's
|
|
@@ -347,6 +347,26 @@ class Settings(BaseSettings):
|
|
|
347
347
|
7, ge=1, le=365, alias="HAMCP_AUTO_BACKUP_CALENDAR_LOOKAHEAD_DAYS"
|
|
348
348
|
)
|
|
349
349
|
|
|
350
|
+
# Snapshot-tarball deletion gate (#1861). Off by default: an agent
|
|
351
|
+
# deleting a full HA snapshot is categorically riskier than the
|
|
352
|
+
# lightweight `edits`-scope auto-backups (which already delete freely),
|
|
353
|
+
# since a snapshot may be the last recovery point after the agent
|
|
354
|
+
# itself broke something. A human must opt in via env var, the web
|
|
355
|
+
# settings UI override file, or (in the add-on) the Supervisor options
|
|
356
|
+
# — never something the agent can flip on itself.
|
|
357
|
+
enable_snapshot_delete: bool = Field(False, alias="ENABLE_SNAPSHOT_DELETE")
|
|
358
|
+
|
|
359
|
+
# Minimum age (days) a snapshot must have before it's deletable. This is
|
|
360
|
+
# the load-bearing guard, not `enable_snapshot_delete`: a count-based
|
|
361
|
+
# "keep the last N" rule is defeatable by an agent flooding new
|
|
362
|
+
# snapshots before deleting old ones, but it cannot forge a backup's
|
|
363
|
+
# HA-stamped creation date. 0 disables the age floor (still gated by
|
|
364
|
+
# enable_snapshot_delete + the newest-snapshot / automatic-backup
|
|
365
|
+
# guards enforced in tools/backup.py).
|
|
366
|
+
snapshot_delete_min_age_days: int = Field(
|
|
367
|
+
7, ge=0, le=365, alias="SNAPSHOT_DELETE_MIN_AGE_DAYS"
|
|
368
|
+
)
|
|
369
|
+
|
|
350
370
|
# Mirror the legacy ``os.getenv("FLAG", "").lower() in ("true", ...)``
|
|
351
371
|
# semantics for the ex-direct-getenv ``enable_filesystem_tools`` flag (and
|
|
352
372
|
# its sibling toggles listed above): an empty env var value MUST be treated
|
|
@@ -1486,6 +1506,10 @@ BACKUP_OVERRIDE_FIELDS: tuple[BackupOverrideField, ...] = (
|
|
|
1486
1506
|
"HAMCP_AUTO_BACKUP_CALENDAR_LOOKAHEAD_DAYS",
|
|
1487
1507
|
int,
|
|
1488
1508
|
),
|
|
1509
|
+
BackupOverrideField("enable_snapshot_delete", "ENABLE_SNAPSHOT_DELETE", bool),
|
|
1510
|
+
BackupOverrideField(
|
|
1511
|
+
"snapshot_delete_min_age_days", "SNAPSHOT_DELETE_MIN_AGE_DAYS", int
|
|
1512
|
+
),
|
|
1489
1513
|
)
|
|
1490
1514
|
|
|
1491
1515
|
# Override-file location is the same data dir that holds tool_config.json
|
|
@@ -1640,6 +1664,13 @@ def _coerce_backup_int_value(field_name: str, raw: object) -> tuple[bool, Any]:
|
|
|
1640
1664
|
coerced,
|
|
1641
1665
|
)
|
|
1642
1666
|
return False, None
|
|
1667
|
+
if field_name == "snapshot_delete_min_age_days" and not 0 <= coerced <= 365:
|
|
1668
|
+
logger.warning(
|
|
1669
|
+
"backup_settings.json: snapshot_delete_min_age_days=%d out of "
|
|
1670
|
+
"range 0..365; ignoring",
|
|
1671
|
+
coerced,
|
|
1672
|
+
)
|
|
1673
|
+
return False, None
|
|
1643
1674
|
return True, coerced
|
|
1644
1675
|
|
|
1645
1676
|
|
{ha_mcp_dev-7.12.3.dev1942 → ha_mcp_dev-7.12.3.dev1949}/src/ha_mcp/settings_ui/_handlers_backups.py
RENAMED
|
@@ -278,6 +278,7 @@ _BACKUP_INT_BOUNDS: dict[str, tuple[int, int]] = {
|
|
|
278
278
|
"auto_backup_throttle_minutes": (0, 1440),
|
|
279
279
|
"auto_backup_retain_per_entity": (1, 10_000),
|
|
280
280
|
"auto_backup_calendar_lookahead_days": (1, 365),
|
|
281
|
+
"snapshot_delete_min_age_days": (0, 365),
|
|
281
282
|
}
|
|
282
283
|
|
|
283
284
|
|
|
@@ -1053,6 +1053,14 @@ const BACKUP_FIELD_LABELS = {
|
|
|
1053
1053
|
label: 'Calendar lookahead (days)',
|
|
1054
1054
|
help: 'How far ahead to query for calendar events when capturing pre-edit snapshots. Range 1–365.',
|
|
1055
1055
|
},
|
|
1056
|
+
enable_snapshot_delete: {
|
|
1057
|
+
label: 'Allow snapshot deletion',
|
|
1058
|
+
help: 'Lets ha_manage_backup delete full HA snapshot tarballs. Off by default: a snapshot may be the last recovery point after a mistaken change. Even when on, scheduled backups, the newest remaining snapshot, and anything younger than the age floor below stay protected.',
|
|
1059
|
+
},
|
|
1060
|
+
snapshot_delete_min_age_days: {
|
|
1061
|
+
label: 'Minimum snapshot age to delete (days)',
|
|
1062
|
+
help: 'A snapshot must be at least this old before it can be deleted. Range 0–365; 0 disables the floor (the newest-snapshot and scheduled-backup protections still apply).',
|
|
1063
|
+
},
|
|
1056
1064
|
};
|
|
1057
1065
|
|
|
1058
1066
|
const BACKUP_ORIGIN_LABELS = {
|
|
@@ -1104,6 +1112,7 @@ function renderBackupConfig() {
|
|
|
1104
1112
|
let max = 10000;
|
|
1105
1113
|
if (f.field === 'auto_backup_throttle_minutes') { min = 0; max = 1440; }
|
|
1106
1114
|
else if (f.field === 'auto_backup_calendar_lookahead_days') { min = 1; max = 365; }
|
|
1115
|
+
else if (f.field === 'snapshot_delete_min_age_days') { min = 0; max = 365; }
|
|
1107
1116
|
controlHtml = `<input type="number" name="backup:${escapeHtml(f.field)}" data-field="${escapeHtml(f.field)}" aria-labelledby="label-backup-${escapeHtml(f.field)}" value="${Number(f.value)}" min="${min}" max="${max}" ${f.editable ? '' : 'disabled'}>`;
|
|
1108
1117
|
}
|
|
1109
1118
|
let originMsg;
|
|
@@ -19,8 +19,9 @@ HA restore path. Each call must explicitly pick its scope.
|
|
|
19
19
|
import asyncio
|
|
20
20
|
import logging
|
|
21
21
|
from datetime import UTC, datetime, timedelta
|
|
22
|
-
from typing import TYPE_CHECKING, Annotated, Any, Literal, cast
|
|
22
|
+
from typing import TYPE_CHECKING, Annotated, Any, Literal, NoReturn, cast
|
|
23
23
|
|
|
24
|
+
from fastmcp import Context
|
|
24
25
|
from fastmcp.exceptions import ToolError
|
|
25
26
|
from pydantic import Field
|
|
26
27
|
|
|
@@ -38,6 +39,7 @@ from .helpers import (
|
|
|
38
39
|
get_connected_ws_client,
|
|
39
40
|
log_tool_usage,
|
|
40
41
|
raise_tool_error,
|
|
42
|
+
safe_progress,
|
|
41
43
|
)
|
|
42
44
|
|
|
43
45
|
if TYPE_CHECKING:
|
|
@@ -45,12 +47,26 @@ if TYPE_CHECKING:
|
|
|
45
47
|
|
|
46
48
|
logger = logging.getLogger(__name__)
|
|
47
49
|
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
+
# Poll window for full HA backups. HA's own frontend imposes no timeout at
|
|
51
|
+
# all on backup creation — it subscribes to `backup/subscribe_events` and
|
|
52
|
+
# waits however long the job takes. Our poll-based design still needs a
|
|
53
|
+
# finite bound per tool call, so this mirrors _SAFETY_BACKUP_MAX_WAIT_S (the
|
|
54
|
+
# codebase's existing "generous but bounded" ceiling) rather than inventing a
|
|
55
|
+
# third number.
|
|
56
|
+
#
|
|
57
|
+
# Two distinct problems, both fixed in the same PR, by different mechanisms:
|
|
58
|
+
# the client-side abort reported in #1861 (an MCP client that received no
|
|
59
|
+
# progress notifications for 300s concluded the connection was dead) is
|
|
60
|
+
# fixed by the ctx.report_progress heartbeats in _poll_backup_completion,
|
|
61
|
+
# not by this constant. Raising it from 300 to 1800 instead fixes *our own*
|
|
62
|
+
# poll loop giving up on a legitimately >5-minute backup — the job keeps
|
|
63
|
+
# running server-side after our loop times out, so a too-tight ceiling here
|
|
64
|
+
# means _poll_backup_completion raises TIMEOUT_OPERATION on a backup that
|
|
65
|
+
# would otherwise have succeeded. 120s before that underfit slow HA
|
|
66
|
+
# instances too (#1433: poll loop exited while HA was still in
|
|
50
67
|
# state="create_backup", the wrapper treated that as failure, retried, and
|
|
51
|
-
# produced duplicate backups).
|
|
52
|
-
|
|
53
|
-
_BACKUP_MAX_WAIT_S = 300
|
|
68
|
+
# produced duplicate backups).
|
|
69
|
+
_BACKUP_MAX_WAIT_S = 1800
|
|
54
70
|
_BACKUP_POLL_INTERVAL_S = 2
|
|
55
71
|
# The pre-restore safety backup is a *full* backup (include_database=True, plus
|
|
56
72
|
# all add-ons on Supervised), unlike the fast create_backup path. A multi-GB
|
|
@@ -60,6 +76,19 @@ _BACKUP_POLL_INTERVAL_S = 2
|
|
|
60
76
|
_SAFETY_BACKUP_MAX_WAIT_S = 1800
|
|
61
77
|
# Clock-skew tolerance when filtering backup entries by date vs job-start.
|
|
62
78
|
_BACKUP_DATE_FILTER_TOLERANCE_S = 5
|
|
79
|
+
# Minimum spacing between in-loop MCP progress heartbeats during a backup
|
|
80
|
+
# poll (#1861: with no progress notifications at all, a client can decide
|
|
81
|
+
# the connection is dead and abort mid-backup — "sent no response or
|
|
82
|
+
# progress for 300s"). Independent of poll_interval so a tight poll loop
|
|
83
|
+
# doesn't flood the client with redundant notifications.
|
|
84
|
+
_BACKUP_PROGRESS_INTERVAL_S = 10
|
|
85
|
+
# backup/delete fans out to every configured agent (including cloud/remote
|
|
86
|
+
# ones — S3, Google Drive, WebDAV, etc.) via asyncio.gather server-side, and
|
|
87
|
+
# the WS response doesn't arrive until all of them finish. The client's
|
|
88
|
+
# default 30s wait (HomeAssistantWebSocketClient.send_command's
|
|
89
|
+
# _wait_timeout) can be too tight for a slow or rate-limited remote agent —
|
|
90
|
+
# same class of false-timeout problem this file already fixes for creation.
|
|
91
|
+
_BACKUP_DELETE_WAIT_S = 60.0
|
|
63
92
|
|
|
64
93
|
|
|
65
94
|
def _get_backup_hint_text() -> str:
|
|
@@ -288,6 +317,7 @@ async def _poll_backup_completion(
|
|
|
288
317
|
max_wait_seconds: int,
|
|
289
318
|
poll_interval: int,
|
|
290
319
|
agent_id: str,
|
|
320
|
+
ctx: Context | None = None,
|
|
291
321
|
) -> dict[str, Any]:
|
|
292
322
|
"""Poll backup/info until the named backup completes, fails, or times out.
|
|
293
323
|
|
|
@@ -304,14 +334,34 @@ async def _poll_backup_completion(
|
|
|
304
334
|
creation in progress, surfaces ``likely_in_progress=true`` in the error
|
|
305
335
|
context so callers can back off retries instead of compounding duplicates.
|
|
306
336
|
|
|
337
|
+
``ctx``, when supplied, receives a ``report_progress`` heartbeat before
|
|
338
|
+
the first poll and at least every ``_BACKUP_PROGRESS_INTERVAL_S`` while
|
|
339
|
+
waiting, so an MCP client watching for "no response or progress" doesn't
|
|
340
|
+
abort the connection mid-backup (#1861).
|
|
341
|
+
|
|
307
342
|
Raises ToolError on backup failure or final timeout with no completion.
|
|
308
343
|
"""
|
|
309
344
|
job_start_ts = datetime.now(UTC)
|
|
310
345
|
waited = 0
|
|
346
|
+
next_progress_at = _BACKUP_PROGRESS_INTERVAL_S
|
|
347
|
+
await safe_progress(
|
|
348
|
+
ctx,
|
|
349
|
+
progress=0,
|
|
350
|
+
total=max_wait_seconds,
|
|
351
|
+
message="waiting for backup to complete",
|
|
352
|
+
)
|
|
311
353
|
|
|
312
354
|
while waited < max_wait_seconds:
|
|
313
355
|
await asyncio.sleep(poll_interval)
|
|
314
356
|
waited += poll_interval
|
|
357
|
+
if waited >= next_progress_at:
|
|
358
|
+
await safe_progress(
|
|
359
|
+
ctx,
|
|
360
|
+
progress=waited,
|
|
361
|
+
total=max_wait_seconds,
|
|
362
|
+
message=f"waiting for backup to complete ({waited}s elapsed)",
|
|
363
|
+
)
|
|
364
|
+
next_progress_at = waited + _BACKUP_PROGRESS_INTERVAL_S
|
|
315
365
|
|
|
316
366
|
info_result = await ws_client.send_command("backup/info")
|
|
317
367
|
if not info_result.get("success"):
|
|
@@ -448,7 +498,9 @@ async def _poll_backup_completion(
|
|
|
448
498
|
|
|
449
499
|
|
|
450
500
|
async def create_backup(
|
|
451
|
-
client: HomeAssistantClient,
|
|
501
|
+
client: HomeAssistantClient,
|
|
502
|
+
name: str | None = None,
|
|
503
|
+
ctx: Context | None = None,
|
|
452
504
|
) -> dict[str, Any]:
|
|
453
505
|
"""
|
|
454
506
|
Create a fast Home Assistant backup (local only, excludes database).
|
|
@@ -456,6 +508,7 @@ async def create_backup(
|
|
|
456
508
|
Args:
|
|
457
509
|
client: Home Assistant REST client
|
|
458
510
|
name: Optional backup name (auto-generated if not provided)
|
|
511
|
+
ctx: Optional FastMCP context for progress heartbeats during the wait
|
|
459
512
|
|
|
460
513
|
Returns:
|
|
461
514
|
Dictionary with backup result including backup_id, status, duration, etc.
|
|
@@ -528,6 +581,7 @@ async def create_backup(
|
|
|
528
581
|
max_wait_seconds=_BACKUP_MAX_WAIT_S,
|
|
529
582
|
poll_interval=_BACKUP_POLL_INTERVAL_S,
|
|
530
583
|
agent_id=local_agent,
|
|
584
|
+
ctx=ctx,
|
|
531
585
|
)
|
|
532
586
|
|
|
533
587
|
except ToolError:
|
|
@@ -558,6 +612,7 @@ async def _create_safety_backup(
|
|
|
558
612
|
ws_client: HomeAssistantWebSocketClient,
|
|
559
613
|
password: str | None,
|
|
560
614
|
agent_id: str,
|
|
615
|
+
ctx: Context | None = None,
|
|
561
616
|
) -> tuple[str | None, list[str]]:
|
|
562
617
|
"""Create a pre-restore safety backup and wait for it to complete.
|
|
563
618
|
|
|
@@ -620,6 +675,7 @@ async def _create_safety_backup(
|
|
|
620
675
|
max_wait_seconds=_SAFETY_BACKUP_MAX_WAIT_S,
|
|
621
676
|
poll_interval=_BACKUP_POLL_INTERVAL_S,
|
|
622
677
|
agent_id=agent_id,
|
|
678
|
+
ctx=ctx,
|
|
623
679
|
)
|
|
624
680
|
logger.info(f"Safety backup completed: {safety_backup_id}")
|
|
625
681
|
return cast(str, safety_backup_id), poll_result.get("warnings", [])
|
|
@@ -645,7 +701,10 @@ def _backup_protected(entry: dict[str, Any]) -> bool | None:
|
|
|
645
701
|
|
|
646
702
|
|
|
647
703
|
async def restore_backup(
|
|
648
|
-
client: HomeAssistantClient,
|
|
704
|
+
client: HomeAssistantClient,
|
|
705
|
+
backup_id: str,
|
|
706
|
+
restore_database: bool = False,
|
|
707
|
+
ctx: Context | None = None,
|
|
649
708
|
) -> dict[str, Any]:
|
|
650
709
|
"""
|
|
651
710
|
Restore Home Assistant from a backup (DESTRUCTIVE - use with caution).
|
|
@@ -661,6 +720,8 @@ async def restore_backup(
|
|
|
661
720
|
match when restoring Home Assistant; a warning is returned if the
|
|
662
721
|
value is overridden. On Core installs the caller's value is honoured
|
|
663
722
|
as-is (Core enforces no such match).
|
|
723
|
+
ctx: Optional FastMCP context for progress heartbeats during the
|
|
724
|
+
pre-restore safety-backup wait
|
|
664
725
|
|
|
665
726
|
Returns:
|
|
666
727
|
Dictionary with restore result including safety_backup_id, status, etc.
|
|
@@ -722,7 +783,7 @@ async def restore_backup(
|
|
|
722
783
|
password = None
|
|
723
784
|
|
|
724
785
|
safety_backup_id, safety_warnings = await _create_safety_backup(
|
|
725
|
-
ws_client, password, local_agent
|
|
786
|
+
ws_client, password, local_agent, ctx=ctx
|
|
726
787
|
)
|
|
727
788
|
|
|
728
789
|
# `backup/info` returns ManagerBackup entries: `database_included` is a
|
|
@@ -950,12 +1011,263 @@ async def list_backups(client: HomeAssistantClient, limit: int = 200) -> dict[st
|
|
|
950
1011
|
return None # unreachable: exception_to_structured_error always raises
|
|
951
1012
|
|
|
952
1013
|
|
|
1014
|
+
def _refuse_snapshot_delete(message: str, **context: Any) -> NoReturn:
|
|
1015
|
+
"""Raise the shared VALIDATION_INVALID_PARAMETER shape for every
|
|
1016
|
+
snapshot-delete guard rejection (gate, confirm, automatic-settings, age
|
|
1017
|
+
floor, newest-snapshot protection)."""
|
|
1018
|
+
raise_tool_error(
|
|
1019
|
+
create_error_response(
|
|
1020
|
+
ErrorCode.VALIDATION_INVALID_PARAMETER,
|
|
1021
|
+
message,
|
|
1022
|
+
context=context,
|
|
1023
|
+
)
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _newest_backup_id(backups: list[dict[str, Any]]) -> str | None:
|
|
1028
|
+
"""Return the ``backup_id`` of the single most-recent entry by date, or
|
|
1029
|
+
None if no entry has a parseable date.
|
|
1030
|
+
|
|
1031
|
+
Entries with a missing/malformed date are excluded from consideration —
|
|
1032
|
+
they can never be "the newest" — but that does not make them safe to
|
|
1033
|
+
delete; ``delete_backup`` fails closed on an unparseable target date
|
|
1034
|
+
separately, so an undated target can never be deleted regardless of
|
|
1035
|
+
this function's result.
|
|
1036
|
+
"""
|
|
1037
|
+
dated = [
|
|
1038
|
+
(b.get("backup_id"), parsed)
|
|
1039
|
+
for b in backups
|
|
1040
|
+
if (parsed := _parse_backup_date(b.get("date"))) is not None
|
|
1041
|
+
]
|
|
1042
|
+
if not dated:
|
|
1043
|
+
return None
|
|
1044
|
+
return max(dated, key=lambda pair: pair[1])[0]
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
async def delete_backup(
|
|
1048
|
+
client: HomeAssistantClient,
|
|
1049
|
+
backup_id: str,
|
|
1050
|
+
confirm: bool = False,
|
|
1051
|
+
ctx: Context | None = None,
|
|
1052
|
+
) -> dict[str, Any]:
|
|
1053
|
+
"""Delete one full HA snapshot tarball (#1861).
|
|
1054
|
+
|
|
1055
|
+
Off by default (``enable_snapshot_delete``) — a human must opt in via
|
|
1056
|
+
env var, the web settings UI override file, or (in the add-on) the
|
|
1057
|
+
Supervisor options; an agent cannot enable this itself. Layered guards
|
|
1058
|
+
beyond the gate, applied in order:
|
|
1059
|
+
|
|
1060
|
+
1. ``confirm=True`` is required.
|
|
1061
|
+
2. ``backup/info`` must not report ``agent_errors`` — evaluated before
|
|
1062
|
+
the "does it exist" check right below, since an agent that failed
|
|
1063
|
+
to enumerate its backups could make that list (and every guard
|
|
1064
|
+
below) incomplete. The backup must then exist (verified against
|
|
1065
|
+
``backup/info``, mirroring ``restore_backup`` — HA's own
|
|
1066
|
+
``backup/delete`` silently no-ops on an unknown ID rather than
|
|
1067
|
+
erroring).
|
|
1068
|
+
3. Scheduled backups (``with_automatic_settings=True``) are never
|
|
1069
|
+
deletable. Fails closed the same way when HA can't confirm the
|
|
1070
|
+
backup is manual (``with_automatic_settings=None`` — not created
|
|
1071
|
+
by this HA instance, or predates this metadata field): only an
|
|
1072
|
+
explicit ``False`` is treated as deletable.
|
|
1073
|
+
4. The single newest remaining snapshot, of any type, is never
|
|
1074
|
+
deletable — guarantees at least one recovery point always survives.
|
|
1075
|
+
Checked before the age floor below so a backup that is both the
|
|
1076
|
+
newest AND too young reports "newest" (with its own remedy), not
|
|
1077
|
+
a "too young" message whose suggested remedy (lower the floor)
|
|
1078
|
+
would not actually unblock it.
|
|
1079
|
+
5. The target must be older than ``snapshot_delete_min_age_days`` (0
|
|
1080
|
+
unconditionally disables this floor, even across clock skew between
|
|
1081
|
+
this host and the HA instance that stamped the backup's date). A
|
|
1082
|
+
missing/unparseable date fails closed (treated as too new to delete).
|
|
1083
|
+
|
|
1084
|
+
The actual ``backup/delete`` call uses an elevated WS wait timeout
|
|
1085
|
+
(``_BACKUP_DELETE_WAIT_S``) since it fans out to every configured agent
|
|
1086
|
+
— including slow/rate-limited remote ones — server-side. ``ctx``, when
|
|
1087
|
+
supplied, receives one ``report_progress`` heartbeat immediately before
|
|
1088
|
+
that call.
|
|
1089
|
+
|
|
1090
|
+
Raises ToolError if disabled, unconfirmed, not found, blocked by a
|
|
1091
|
+
guard, or if HA reports a per-agent deletion failure.
|
|
1092
|
+
"""
|
|
1093
|
+
settings = get_global_settings()
|
|
1094
|
+
if not settings.enable_snapshot_delete:
|
|
1095
|
+
_refuse_snapshot_delete(
|
|
1096
|
+
"Snapshot deletion is disabled on this server "
|
|
1097
|
+
"(enable_snapshot_delete=false). A human must enable it via the "
|
|
1098
|
+
"ENABLE_SNAPSHOT_DELETE env var, the web settings UI, or (in "
|
|
1099
|
+
"the add-on) the Supervisor options — this cannot be turned on "
|
|
1100
|
+
"from a tool call.",
|
|
1101
|
+
)
|
|
1102
|
+
if not confirm:
|
|
1103
|
+
_refuse_snapshot_delete(
|
|
1104
|
+
"Deletion not confirmed. Set confirm=True to delete a snapshot.",
|
|
1105
|
+
backup_id=backup_id,
|
|
1106
|
+
)
|
|
1107
|
+
|
|
1108
|
+
ws_client = None
|
|
1109
|
+
try:
|
|
1110
|
+
ws_client, error = await get_connected_ws_client(
|
|
1111
|
+
client.base_url, client.token, verify_ssl=client.verify_ssl
|
|
1112
|
+
)
|
|
1113
|
+
if error:
|
|
1114
|
+
raise_tool_error(
|
|
1115
|
+
error
|
|
1116
|
+
or create_error_response(
|
|
1117
|
+
ErrorCode.CONNECTION_FAILED,
|
|
1118
|
+
"Failed to connect to Home Assistant WebSocket for backup deletion",
|
|
1119
|
+
)
|
|
1120
|
+
)
|
|
1121
|
+
ws_client = cast(HomeAssistantWebSocketClient, ws_client)
|
|
1122
|
+
|
|
1123
|
+
info_result = await ws_client.send_command("backup/info")
|
|
1124
|
+
if not info_result.get("success"):
|
|
1125
|
+
raise_tool_error(
|
|
1126
|
+
create_error_response(
|
|
1127
|
+
ErrorCode.SERVICE_CALL_FAILED,
|
|
1128
|
+
info_result.get("error", "Failed to retrieve backup information"),
|
|
1129
|
+
)
|
|
1130
|
+
)
|
|
1131
|
+
info_result_block = info_result.get("result") or {}
|
|
1132
|
+
info_agent_errors = info_result_block.get("agent_errors") or {}
|
|
1133
|
+
if info_agent_errors:
|
|
1134
|
+
raise_tool_error(
|
|
1135
|
+
create_error_response(
|
|
1136
|
+
ErrorCode.SERVICE_CALL_FAILED,
|
|
1137
|
+
"Cannot verify the backup inventory is complete: one or "
|
|
1138
|
+
"more backup agents failed to respond, so the newest-"
|
|
1139
|
+
"snapshot and scheduled-backup guards can't be trusted "
|
|
1140
|
+
"against a possibly-partial list.",
|
|
1141
|
+
context={"backup_id": backup_id, "agent_errors": info_agent_errors},
|
|
1142
|
+
)
|
|
1143
|
+
)
|
|
1144
|
+
backups = info_result_block.get("backups") or []
|
|
1145
|
+
matched = next((b for b in backups if b.get("backup_id") == backup_id), None)
|
|
1146
|
+
if matched is None:
|
|
1147
|
+
raise_tool_error(
|
|
1148
|
+
create_error_response(
|
|
1149
|
+
ErrorCode.RESOURCE_NOT_FOUND,
|
|
1150
|
+
f"Backup '{backup_id}' not found",
|
|
1151
|
+
suggestions=[
|
|
1152
|
+
"Use ha_manage_backup(scope='snapshot', action='list') "
|
|
1153
|
+
"to see available backup IDs",
|
|
1154
|
+
],
|
|
1155
|
+
)
|
|
1156
|
+
)
|
|
1157
|
+
|
|
1158
|
+
if matched.get("with_automatic_settings") is not False:
|
|
1159
|
+
_refuse_snapshot_delete(
|
|
1160
|
+
"Refusing to delete a scheduled (automatic) backup, or one "
|
|
1161
|
+
"whose automatic/manual origin Home Assistant cannot "
|
|
1162
|
+
"confirm (with_automatic_settings=None — not created by "
|
|
1163
|
+
"this HA instance, or predates this metadata) — treated "
|
|
1164
|
+
"as not provably manual, and therefore not deletable.",
|
|
1165
|
+
backup_id=backup_id,
|
|
1166
|
+
)
|
|
1167
|
+
|
|
1168
|
+
# Checked before the age floor below: a backup that is BOTH the
|
|
1169
|
+
# newest AND too young must report "newest" (create a new one
|
|
1170
|
+
# first), not "too young" — the age-floor message's remedy
|
|
1171
|
+
# ("lower snapshot_delete_min_age_days") would be actively
|
|
1172
|
+
# misleading advice for the newest snapshot, which stays
|
|
1173
|
+
# protected at any age floor including 0.
|
|
1174
|
+
newest_id = _newest_backup_id(backups)
|
|
1175
|
+
if newest_id == backup_id:
|
|
1176
|
+
_refuse_snapshot_delete(
|
|
1177
|
+
"Refusing to delete the newest snapshot — at least one "
|
|
1178
|
+
"recovery point must remain. Create a new snapshot first "
|
|
1179
|
+
"if you specifically need to free this one's space.",
|
|
1180
|
+
backup_id=backup_id,
|
|
1181
|
+
)
|
|
1182
|
+
|
|
1183
|
+
min_age_days = settings.snapshot_delete_min_age_days
|
|
1184
|
+
target_date = _parse_backup_date(matched.get("date"))
|
|
1185
|
+
if target_date is None:
|
|
1186
|
+
_refuse_snapshot_delete(
|
|
1187
|
+
"Refusing to delete a backup with a missing or unparseable "
|
|
1188
|
+
"date — cannot verify it clears the minimum-age floor.",
|
|
1189
|
+
backup_id=backup_id,
|
|
1190
|
+
)
|
|
1191
|
+
cutoff = datetime.now(UTC) - timedelta(days=min_age_days)
|
|
1192
|
+
# min_age_days=0 must unconditionally disable the floor, even if
|
|
1193
|
+
# target_date is slightly ahead of this host's clock (clock skew
|
|
1194
|
+
# vs the HA instance that stamped it) — guard explicitly rather
|
|
1195
|
+
# than relying on the arithmetic reduction, which clock skew breaks.
|
|
1196
|
+
if min_age_days > 0 and target_date > cutoff:
|
|
1197
|
+
_refuse_snapshot_delete(
|
|
1198
|
+
f"Refusing to delete a backup younger than "
|
|
1199
|
+
f"snapshot_delete_min_age_days={min_age_days} days — at "
|
|
1200
|
+
"least one recent recovery point must remain. Delete an "
|
|
1201
|
+
"older backup, or lower snapshot_delete_min_age_days if "
|
|
1202
|
+
"this recurs.",
|
|
1203
|
+
backup_id=backup_id,
|
|
1204
|
+
)
|
|
1205
|
+
|
|
1206
|
+
await safe_progress(
|
|
1207
|
+
ctx,
|
|
1208
|
+
progress=0,
|
|
1209
|
+
total=_BACKUP_DELETE_WAIT_S,
|
|
1210
|
+
message="deleting backup",
|
|
1211
|
+
)
|
|
1212
|
+
delete_result = await ws_client.send_command(
|
|
1213
|
+
"backup/delete",
|
|
1214
|
+
backup_id=backup_id,
|
|
1215
|
+
_wait_timeout=_BACKUP_DELETE_WAIT_S,
|
|
1216
|
+
)
|
|
1217
|
+
if not delete_result.get("success"):
|
|
1218
|
+
raise_tool_error(
|
|
1219
|
+
create_error_response(
|
|
1220
|
+
ErrorCode.SERVICE_CALL_FAILED,
|
|
1221
|
+
delete_result.get("error", "Backup deletion failed"),
|
|
1222
|
+
context={"backup_id": backup_id},
|
|
1223
|
+
)
|
|
1224
|
+
)
|
|
1225
|
+
agent_errors = (delete_result.get("result") or {}).get("agent_errors") or {}
|
|
1226
|
+
if agent_errors:
|
|
1227
|
+
raise_tool_error(
|
|
1228
|
+
create_error_response(
|
|
1229
|
+
ErrorCode.SERVICE_CALL_FAILED,
|
|
1230
|
+
"Backup deletion failed on one or more agents",
|
|
1231
|
+
context={"backup_id": backup_id, "agent_errors": agent_errors},
|
|
1232
|
+
)
|
|
1233
|
+
)
|
|
1234
|
+
|
|
1235
|
+
return {
|
|
1236
|
+
"success": True,
|
|
1237
|
+
"backup_id": backup_id,
|
|
1238
|
+
"name": matched.get("name"),
|
|
1239
|
+
"status": "Backup deleted successfully",
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
except ToolError:
|
|
1243
|
+
raise
|
|
1244
|
+
except Exception as e:
|
|
1245
|
+
logger.error(f"Error deleting backup: {e}")
|
|
1246
|
+
exception_to_structured_error(
|
|
1247
|
+
e,
|
|
1248
|
+
context={"tool": "delete_backup", "backup_id": backup_id},
|
|
1249
|
+
suggestions=["Check Home Assistant connection and backup availability"],
|
|
1250
|
+
)
|
|
1251
|
+
return None # unreachable: exception_to_structured_error always raises
|
|
1252
|
+
finally:
|
|
1253
|
+
if ws_client:
|
|
1254
|
+
try:
|
|
1255
|
+
await ws_client.disconnect()
|
|
1256
|
+
except (TimeoutError, OSError, ConnectionError) as err:
|
|
1257
|
+
logger.debug(
|
|
1258
|
+
"ws disconnect (cleanup) transport error: %s: %s",
|
|
1259
|
+
type(err).__name__,
|
|
1260
|
+
err,
|
|
1261
|
+
)
|
|
1262
|
+
|
|
1263
|
+
|
|
953
1264
|
# Valid (scope, action) combinations. Anything outside this set is
|
|
954
1265
|
# rejected with a structured VALIDATION_INVALID_PARAMETER error.
|
|
955
1266
|
_VALID_COMBOS: set[tuple[str, str]] = {
|
|
956
1267
|
("snapshot", "create"),
|
|
957
1268
|
("snapshot", "list"),
|
|
958
1269
|
("snapshot", "restore"),
|
|
1270
|
+
("snapshot", "delete"),
|
|
959
1271
|
("edits", "create"),
|
|
960
1272
|
("edits", "list"),
|
|
961
1273
|
("edits", "view"),
|
|
@@ -1019,9 +1331,10 @@ def register_backup_tools(
|
|
|
1019
1331
|
|
|
1020
1332
|
| scope | action | What it does |
|
|
1021
1333
|
|---|---|---|
|
|
1022
|
-
| `snapshot` | `create` | Create a full HA tarball (config + addons, no DB by default).
|
|
1334
|
+
| `snapshot` | `create` | Create a full HA tarball (config + addons, no DB by default). Can take a while on a large instance; progress heartbeats are sent while waiting. |
|
|
1023
1335
|
| `snapshot` | `list` | List full HA tarball snapshots (id, name, date, size). Read-only — use to discover a `backup_id` or confirm a backup landed. |
|
|
1024
1336
|
| `snapshot` | `restore` | Restore a full HA tarball. **Restarts HA.** Last-resort recovery. |
|
|
1337
|
+
| `snapshot` | `delete` | Delete one full HA tarball by `backup_id` (`confirm=True` required). **Disabled by default** (`enable_snapshot_delete` setting) and layered with guards even when enabled — see below. |
|
|
1025
1338
|
| `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. |
|
|
1026
1339
|
| `edits` | `list` | List per-entity auto-backups (lightweight). Filter by `domain` and/or `entity_id`. |
|
|
1027
1340
|
| `edits` | `view` | Read one auto-backup file by name; returns YAML and parsed `config`. |
|
|
@@ -1036,12 +1349,21 @@ def register_backup_tools(
|
|
|
1036
1349
|
**`scope="snapshot"` backup-hint:**
|
|
1037
1350
|
{backup_hint_text}
|
|
1038
1351
|
|
|
1352
|
+
**`(snapshot, delete)` is off by default and layered even when enabled:** a human must
|
|
1353
|
+
set `enable_snapshot_delete=true` (env var, web settings UI, or add-on Supervisor
|
|
1354
|
+
options) — an agent cannot turn this on itself. When enabled, a delete call is still
|
|
1355
|
+
refused if: the target is a scheduled/automatic backup; it's younger than
|
|
1356
|
+
`snapshot_delete_min_age_days` (default 7, 0 disables the floor); or it's the single
|
|
1357
|
+
newest snapshot remaining. These guarantee at least one recovery point always
|
|
1358
|
+
survives an agent's own mistakes.
|
|
1359
|
+
|
|
1039
1360
|
**`enable_auto_backup` and `scope="edits"`:** the automatic-on-write capture (every wrapped tool call) is gated by `enable_auto_backup=true` — if the listing is empty, check the toggle (web settings UI or `ENABLE_AUTO_BACKUP=true` env var). The explicit `(edits, create)` action bypasses the toggle since the request is explicit; `list` / `view` / `restore` / `delete` operate on whatever's already on disk regardless of the toggle's current state.
|
|
1040
1361
|
|
|
1041
1362
|
**Examples:**
|
|
1042
1363
|
- Snapshot before risky op: `ha_manage_backup(scope="snapshot", action="create", name="Before_Big_Change")`
|
|
1043
1364
|
- List snapshots (to discover a backup_id or confirm one landed): `ha_manage_backup(scope="snapshot", action="list")`
|
|
1044
1365
|
- Restore full snapshot: `ha_manage_backup(scope="snapshot", action="restore", backup_id="dd7550ed")`
|
|
1366
|
+
- Delete an old snapshot (requires `enable_snapshot_delete=true`): `ha_manage_backup(scope="snapshot", action="delete", backup_id="dd7550ed", confirm=True)`
|
|
1045
1367
|
- 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")`
|
|
1046
1368
|
- List recent auto-backups for one automation: `ha_manage_backup(scope="edits", action="list", domain="automation", entity_id="kitchen_lights")`
|
|
1047
1369
|
- View an auto-backup: `ha_manage_backup(scope="edits", action="view", backup_name="automation.kitchen_lights.20260521_153000.yaml")`
|
|
@@ -1086,7 +1408,7 @@ def register_backup_tools(
|
|
|
1086
1408
|
str | None,
|
|
1087
1409
|
Field(
|
|
1088
1410
|
default=None,
|
|
1089
|
-
description="(snapshot.restore) Tarball ID
|
|
1411
|
+
description="(snapshot.restore / snapshot.delete) Tarball ID (e.g. 'dd7550ed').",
|
|
1090
1412
|
),
|
|
1091
1413
|
] = None,
|
|
1092
1414
|
restore_database: Annotated[
|
|
@@ -1096,6 +1418,16 @@ def register_backup_tools(
|
|
|
1096
1418
|
description="(snapshot.restore) Include database in the restore. Default false (config-only).",
|
|
1097
1419
|
),
|
|
1098
1420
|
] = False,
|
|
1421
|
+
confirm: Annotated[
|
|
1422
|
+
bool,
|
|
1423
|
+
Field(
|
|
1424
|
+
default=False,
|
|
1425
|
+
description=(
|
|
1426
|
+
"(snapshot.delete) Must be True to confirm deletion — a "
|
|
1427
|
+
"safety measure against accidental calls."
|
|
1428
|
+
),
|
|
1429
|
+
),
|
|
1430
|
+
] = False,
|
|
1099
1431
|
# edits scope params
|
|
1100
1432
|
domain: Annotated[
|
|
1101
1433
|
str | None,
|
|
@@ -1138,18 +1470,22 @@ def register_backup_tools(
|
|
|
1138
1470
|
description="(edits.list / snapshot.list) Maximum number of entries to return.",
|
|
1139
1471
|
),
|
|
1140
1472
|
] = 200,
|
|
1473
|
+
ctx: Context | None = None,
|
|
1141
1474
|
) -> dict[str, Any]:
|
|
1142
1475
|
"""Polymorphic backup tool. See the tool description for the routing matrix."""
|
|
1143
1476
|
_gate_combo(scope, action)
|
|
1144
1477
|
|
|
1145
1478
|
if scope == "snapshot":
|
|
1146
1479
|
if action == "create":
|
|
1147
|
-
return await create_backup(client, name)
|
|
1480
|
+
return await create_backup(client, name, ctx=ctx)
|
|
1148
1481
|
if action == "list":
|
|
1149
1482
|
return await list_backups(client, limit)
|
|
1483
|
+
if action == "delete":
|
|
1484
|
+
bid = _require("backup_id", backup_id, scope, action)
|
|
1485
|
+
return await delete_backup(client, bid, confirm, ctx=ctx)
|
|
1150
1486
|
# action == "restore"
|
|
1151
1487
|
bid = _require("backup_id", backup_id, scope, action)
|
|
1152
|
-
return await restore_backup(client, bid, restore_database)
|
|
1488
|
+
return await restore_backup(client, bid, restore_database, ctx=ctx)
|
|
1153
1489
|
|
|
1154
1490
|
# scope == "edits"
|
|
1155
1491
|
settings = get_global_settings()
|
|
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
|