ha-mcp-dev 7.8.0.dev706__tar.gz → 7.8.0.dev708__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. {ha_mcp_dev-7.8.0.dev706/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.8.0.dev708}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/pyproject.toml +3 -1
  3. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/backup_manager.py +328 -33
  4. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/backup.py +71 -1
  5. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  6. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/LICENSE +0 -0
  7. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/MANIFEST.in +0 -0
  8. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/README.md +0 -0
  9. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/setup.cfg +0 -0
  10. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/__init__.py +0 -0
  11. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/__main__.py +0 -0
  12. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/_pypi_marker +0 -0
  13. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/_version.py +0 -0
  14. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/auth/__init__.py +0 -0
  15. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/auth/consent_form.py +0 -0
  16. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/auth/provider.py +0 -0
  17. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/client/__init__.py +0 -0
  18. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/client/rest_client.py +0 -0
  19. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/client/supervisor_client.py +0 -0
  20. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/client/websocket_client.py +0 -0
  21. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/client/websocket_listener.py +0 -0
  22. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/config.py +0 -0
  23. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/dashboard_screenshot/__init__.py +0 -0
  24. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/dashboard_screenshot/capture.py +0 -0
  25. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/dashboard_screenshot/provision.py +0 -0
  26. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/errors.py +0 -0
  27. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/policy/__init__.py +0 -0
  28. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/policy/approval_queue.py +0 -0
  29. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/policy/evaluator.py +0 -0
  30. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/policy/handlers.py +0 -0
  31. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/policy/middleware.py +0 -0
  32. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/policy/model.py +0 -0
  33. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/policy/persistence.py +0 -0
  34. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/policy/value_sources.py +0 -0
  35. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/py.typed +0 -0
  36. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/read_only.py +0 -0
  37. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  38. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  39. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  40. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  41. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
  42. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  43. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  44. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  45. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  46. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  47. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  48. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  49. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  50. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  51. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  52. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  53. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  54. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  55. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  56. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  57. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  58. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
  59. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/server.py +0 -0
  60. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/settings.css +0 -0
  61. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/settings.js +0 -0
  62. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/settings_ui.py +0 -0
  63. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/smoke_test.py +0 -0
  64. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/stdio_settings_sidecar.py +0 -0
  65. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/__init__.py +0 -0
  66. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/auto_backup.py +0 -0
  67. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  68. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/config_entry_flow.py +0 -0
  69. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/device_control.py +0 -0
  70. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/enhanced.py +0 -0
  71. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/helpers.py +0 -0
  72. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/reference_validator.py +0 -0
  73. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/registry.py +0 -0
  74. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/smart_search/__init__.py +0 -0
  75. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/smart_search/_base.py +0 -0
  76. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/smart_search/_config.py +0 -0
  77. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/smart_search/_deep.py +0 -0
  78. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/smart_search/_entities.py +0 -0
  79. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/smart_search/_fetch.py +0 -0
  80. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/smart_search/_overview.py +0 -0
  81. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/smart_search/_scenes.py +0 -0
  82. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/smart_search/_scoring.py +0 -0
  83. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tool_search_hint_middleware.py +0 -0
  84. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_addons.py +0 -0
  85. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_areas.py +0 -0
  86. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  87. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  88. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_calendar.py +0 -0
  89. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_camera.py +0 -0
  90. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_categories.py +0 -0
  91. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_code.py +0 -0
  92. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  93. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  94. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  95. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
  96. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  97. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_dashboard_screenshot.py +0 -0
  98. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_energy.py +0 -0
  99. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_entities.py +0 -0
  100. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  101. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_groups.py +0 -0
  102. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_hacs.py +0 -0
  103. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_history.py +0 -0
  104. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_integrations.py +0 -0
  105. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_labels.py +0 -0
  106. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  107. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_registry.py +0 -0
  108. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_resources.py +0 -0
  109. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_search.py +0 -0
  110. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_service.py +0 -0
  111. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_services.py +0 -0
  112. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_system.py +0 -0
  113. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_themes.py +0 -0
  114. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_todo.py +0 -0
  115. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_traces.py +0 -0
  116. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_updates.py +0 -0
  117. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_utility.py +0 -0
  118. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  119. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  120. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/tools_zones.py +0 -0
  121. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/util_helpers.py +0 -0
  122. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/tools/validation_middleware.py +0 -0
  123. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/transforms/__init__.py +0 -0
  124. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/transforms/categorized_search.py +0 -0
  125. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
  126. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/utils/__init__.py +0 -0
  127. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/utils/config_hash.py +0 -0
  128. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/utils/data_paths.py +0 -0
  129. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/utils/domain_handlers.py +0 -0
  130. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  131. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  132. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/utils/operation_manager.py +0 -0
  133. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/utils/python_sandbox.py +0 -0
  134. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/utils/skill_loader.py +0 -0
  135. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp/utils/usage_logger.py +0 -0
  136. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  137. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  138. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  139. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  140. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  141. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/tests/__init__.py +0 -0
  142. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/tests/test_constants.py +0 -0
  143. {ha_mcp_dev-7.8.0.dev706 → ha_mcp_dev-7.8.0.dev708}/tests/test_env_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.8.0.dev706
3
+ Version: 7.8.0.dev708
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ha-mcp-dev"
7
- version = "7.8.0.dev706"
7
+ version = "7.8.0.dev708"
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"
@@ -89,6 +89,7 @@ module = [
89
89
  "aiohasupervisor",
90
90
  "aiohasupervisor.*",
91
91
  "aiohttp",
92
+ "yarl",
92
93
  "voluptuous",
93
94
  "jsonschema",
94
95
  "requests",
@@ -186,6 +187,7 @@ asyncio_mode = "auto"
186
187
  dev = [
187
188
  "build>=1.2.2",
188
189
  "docker>=7.1.0",
190
+ "httpx2>=2.4.0",
189
191
  "mypy>=1.17.0",
190
192
  "openai>=1.0.0",
191
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(name)
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
- # ``isinstance(items, list)`` guards in every fetch handler would
655
- # treat the envelope as a non-list and silently return None.
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 = await _ws_send(client, {"type": "lovelace/resources"})
772
- if not isinstance(resources, list):
773
- return None
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 = await _ws_send(client, {"type": "config/label_registry/list"})
812
- if not isinstance(items, list):
813
- return None
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 = await _ws_send(
835
- client, {"type": "config/category_registry/list", "scope": scope}
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
- if not isinstance(result, dict):
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 = await _ws_send(client, {"type": "config/area_registry/list"})
979
- if not isinstance(items, list):
980
- return None
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 = await _ws_send(client, {"type": "config/floor_registry/list"})
986
- if not isinstance(items, list):
987
- return None
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
- if not isinstance(result, dict):
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 = await _ws_send(client, {"type": "config_entries/get"})
1079
- if not isinstance(items, list):
1080
- return None
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 = await _ws_send(client, {"type": f"{helper_type}/list"})
1137
- if not isinstance(items, list):
1138
- return None
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,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.8.0.dev706
3
+ Version: 7.8.0.dev708
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT