ha-mcp-dev 7.3.0.dev372__tar.gz → 7.3.0.dev374__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. {ha_mcp_dev-7.3.0.dev372/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.3.0.dev374}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/pyproject.toml +2 -7
  3. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/backup.py +122 -95
  4. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/best_practice_checker.py +39 -30
  5. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/device_control.py +305 -246
  6. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/helpers.py +111 -99
  7. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/registry.py +39 -33
  8. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_history.py +1 -1
  9. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  10. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/LICENSE +0 -0
  11. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/MANIFEST.in +0 -0
  12. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/README.md +0 -0
  13. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/setup.cfg +0 -0
  14. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/__init__.py +0 -0
  15. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/__main__.py +0 -0
  16. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/_pypi_marker +0 -0
  17. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/auth/__init__.py +0 -0
  18. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/auth/consent_form.py +0 -0
  19. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/auth/provider.py +0 -0
  20. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/client/__init__.py +0 -0
  21. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/client/rest_client.py +0 -0
  22. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/client/websocket_client.py +0 -0
  23. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/client/websocket_listener.py +0 -0
  24. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/config.py +0 -0
  25. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/errors.py +0 -0
  26. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/py.typed +0 -0
  27. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  28. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  29. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  30. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  31. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  32. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  33. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  34. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  35. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  36. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  37. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  38. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  39. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  40. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  41. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  42. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  43. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  44. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  45. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  46. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  47. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/server.py +0 -0
  48. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/smoke_test.py +0 -0
  49. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/__init__.py +0 -0
  50. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/enhanced.py +0 -0
  51. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/smart_search.py +0 -0
  52. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_addons.py +0 -0
  53. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_areas.py +0 -0
  54. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  55. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  56. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_calendar.py +0 -0
  57. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_camera.py +0 -0
  58. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_categories.py +0 -0
  59. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  60. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  61. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  62. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  63. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  64. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_entities.py +0 -0
  65. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  66. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_groups.py +0 -0
  67. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_hacs.py +0 -0
  68. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_integrations.py +0 -0
  69. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_labels.py +0 -0
  70. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  71. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_registry.py +0 -0
  72. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_resources.py +0 -0
  73. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_search.py +0 -0
  74. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_service.py +0 -0
  75. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_services.py +0 -0
  76. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_system.py +0 -0
  77. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_todo.py +0 -0
  78. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_traces.py +0 -0
  79. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_updates.py +0 -0
  80. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_utility.py +0 -0
  81. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  82. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  83. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/tools_zones.py +0 -0
  84. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/tools/util_helpers.py +0 -0
  85. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/transforms/__init__.py +0 -0
  86. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/transforms/categorized_search.py +0 -0
  87. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/utils/__init__.py +0 -0
  88. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/utils/config_hash.py +0 -0
  89. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/utils/domain_handlers.py +0 -0
  90. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  91. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/utils/operation_manager.py +0 -0
  92. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/utils/python_sandbox.py +0 -0
  93. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp/utils/usage_logger.py +0 -0
  94. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  95. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  96. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  97. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  98. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  99. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/tests/__init__.py +0 -0
  100. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/tests/test_constants.py +0 -0
  101. {ha_mcp_dev-7.3.0.dev372 → ha_mcp_dev-7.3.0.dev374}/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.3.0.dev372
3
+ Version: 7.3.0.dev374
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.3.0.dev372"
7
+ version = "7.3.0.dev374"
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"
@@ -135,12 +135,6 @@ ignore = [
135
135
  "tests/**/*" = ["E501", "B011"]
136
136
  # C901 ignores for tools files with complex methods (see #925).
137
137
  # Remove lines as individual methods are simplified below threshold.
138
- "src/ha_mcp/tools/backup.py" = ["C901"]
139
- "src/ha_mcp/tools/best_practice_checker.py" = ["C901"]
140
- "src/ha_mcp/tools/device_control.py" = ["C901"]
141
- "src/ha_mcp/tools/helpers.py" = ["C901"]
142
- "src/ha_mcp/tools/registry.py" = ["C901"]
143
- "src/ha_mcp/tools/smart_search.py" = ["C901"]
144
138
  "src/ha_mcp/tools/tools_addons.py" = ["C901"]
145
139
  "src/ha_mcp/tools/tools_config_dashboards.py" = ["C901"]
146
140
  "src/ha_mcp/tools/tools_config_helpers.py" = ["C901"]
@@ -148,6 +142,7 @@ ignore = [
148
142
  "src/ha_mcp/tools/tools_registry.py" = ["C901"]
149
143
  "src/ha_mcp/tools/tools_search.py" = ["C901"]
150
144
  "src/ha_mcp/tools/tools_utility.py" = ["C901"]
145
+ "src/ha_mcp/tools/smart_search.py" = ["C901"]
151
146
  "src/ha_mcp/tools/util_helpers.py" = ["C901"]
152
147
 
153
148
  [tool.pytest.ini_options]
@@ -27,6 +27,9 @@ if TYPE_CHECKING:
27
27
 
28
28
  logger = logging.getLogger(__name__)
29
29
 
30
+ _BACKUP_MAX_WAIT_S = 120
31
+ _BACKUP_POLL_INTERVAL_S = 2
32
+
30
33
 
31
34
  def _get_backup_hint_text() -> str:
32
35
  """
@@ -85,6 +88,80 @@ async def _get_backup_password(
85
88
  return cast(str, default_password)
86
89
 
87
90
 
91
+ async def _poll_backup_completion(
92
+ ws_client: HomeAssistantWebSocketClient,
93
+ name: str,
94
+ backup_job_id: str,
95
+ max_wait_seconds: int,
96
+ poll_interval: int,
97
+ ) -> dict[str, Any]:
98
+ """Poll backup/info until the named backup completes, fails, or times out.
99
+
100
+ Raises ToolError on backup failure or timeout.
101
+ """
102
+ waited = 0
103
+
104
+ while waited < max_wait_seconds:
105
+ await asyncio.sleep(poll_interval)
106
+ waited += poll_interval
107
+
108
+ info_result = await ws_client.send_command("backup/info")
109
+ if info_result.get("success"):
110
+ state = info_result.get("result", {}).get("state")
111
+ last_event = info_result.get("result", {}).get("last_action_event", {})
112
+ event_state = last_event.get("state")
113
+
114
+ logger.debug(
115
+ f"Backup state: {state}, event_state: {event_state}, waited: {waited}s"
116
+ )
117
+
118
+ if state == "idle" and event_state == "completed":
119
+ backups = info_result.get("result", {}).get("backups", [])
120
+ created_backup = None
121
+ for backup in backups:
122
+ if backup.get("name") == name:
123
+ created_backup = backup
124
+ break
125
+
126
+ if created_backup:
127
+ logger.info(
128
+ f"Backup completed successfully: {created_backup.get('backup_id')}"
129
+ )
130
+ return {
131
+ "success": True,
132
+ "backup_id": created_backup.get("backup_id"),
133
+ "backup_job_id": backup_job_id,
134
+ "name": name,
135
+ "date": created_backup.get("date"),
136
+ "size_bytes": created_backup.get("agents", {})
137
+ .get("hassio.local", {})
138
+ .get("size"),
139
+ "status": "Backup completed successfully",
140
+ "duration_seconds": waited,
141
+ "note": "Backup uses your Home Assistant's default backup password",
142
+ }
143
+ else:
144
+ logger.warning(
145
+ "Backup completed but not found in backup list yet, waiting..."
146
+ )
147
+ continue
148
+
149
+ elif event_state == "failed":
150
+ raise_tool_error(create_error_response(
151
+ ErrorCode.SERVICE_CALL_FAILED,
152
+ "Backup creation failed",
153
+ context={"backup_job_id": backup_job_id},
154
+ ))
155
+
156
+ logger.warning(f"Backup did not complete within {max_wait_seconds} seconds")
157
+ raise_tool_error(create_error_response(
158
+ ErrorCode.TIMEOUT_OPERATION,
159
+ f"Backup creation timed out after {max_wait_seconds} seconds",
160
+ context={"backup_job_id": backup_job_id, "name": name},
161
+ suggestions=["Backup may still be in progress. Check Home Assistant backup status."],
162
+ ))
163
+
164
+
88
165
  async def create_backup(
89
166
  client: HomeAssistantClient, name: str | None = None
90
167
  ) -> dict[str, Any]:
@@ -140,76 +217,13 @@ async def create_backup(
140
217
  backup_job_id = result.get("result", {}).get("backup_job_id")
141
218
  logger.info(f"Backup job started: {backup_job_id}, waiting for completion...")
142
219
 
143
- # Wait for backup to complete by polling backup/info
144
- max_wait_seconds = 120 # 2 minutes max wait
145
- poll_interval = 2 # Check every 2 seconds
146
- waited = 0
147
-
148
- while waited < max_wait_seconds:
149
- await asyncio.sleep(poll_interval)
150
- waited += poll_interval
151
-
152
- # Check backup status
153
- info_result = await ws_client.send_command("backup/info")
154
- if info_result.get("success"):
155
- state = info_result.get("result", {}).get("state")
156
- last_event = info_result.get("result", {}).get("last_action_event", {})
157
- event_state = last_event.get("state")
158
-
159
- logger.debug(
160
- f"Backup state: {state}, event_state: {event_state}, waited: {waited}s"
161
- )
162
-
163
- # Check if backup is complete
164
- if state == "idle" and event_state == "completed":
165
- # Find the backup that was just created
166
- backups = info_result.get("result", {}).get("backups", [])
167
- created_backup = None
168
- for backup in backups:
169
- if backup.get("name") == name:
170
- created_backup = backup
171
- break
172
-
173
- if created_backup:
174
- logger.info(
175
- f"Backup completed successfully: {created_backup.get('backup_id')}"
176
- )
177
- return {
178
- "success": True,
179
- "backup_id": created_backup.get("backup_id"),
180
- "backup_job_id": backup_job_id,
181
- "name": name,
182
- "date": created_backup.get("date"),
183
- "size_bytes": created_backup.get("agents", {})
184
- .get("hassio.local", {})
185
- .get("size"),
186
- "status": "Backup completed successfully",
187
- "duration_seconds": waited,
188
- "note": "Backup uses your Home Assistant's default backup password",
189
- }
190
- else:
191
- # Backup completed but not found in list yet
192
- logger.warning(
193
- "Backup completed but not found in backup list yet, waiting..."
194
- )
195
- continue
196
-
197
- # Check if backup failed
198
- elif event_state == "failed":
199
- raise_tool_error(create_error_response(
200
- ErrorCode.SERVICE_CALL_FAILED,
201
- "Backup creation failed",
202
- context={"backup_job_id": backup_job_id},
203
- ))
204
-
205
- # Timeout waiting for backup
206
- logger.warning(f"Backup did not complete within {max_wait_seconds} seconds")
207
- raise_tool_error(create_error_response(
208
- ErrorCode.TIMEOUT_OPERATION,
209
- f"Backup creation timed out after {max_wait_seconds} seconds",
210
- context={"backup_job_id": backup_job_id, "name": name},
211
- suggestions=["Backup may still be in progress. Check Home Assistant backup status."],
212
- ))
220
+ return await _poll_backup_completion(
221
+ ws_client,
222
+ name,
223
+ backup_job_id,
224
+ max_wait_seconds=_BACKUP_MAX_WAIT_S,
225
+ poll_interval=_BACKUP_POLL_INTERVAL_S,
226
+ )
213
227
 
214
228
  except ToolError:
215
229
  raise
@@ -229,6 +243,43 @@ async def create_backup(
229
243
  pass # Ignore errors during cleanup
230
244
 
231
245
 
246
+ async def _create_safety_backup(
247
+ ws_client: HomeAssistantWebSocketClient,
248
+ password: str | None,
249
+ ) -> str | None:
250
+ """Create a pre-restore safety backup.
251
+
252
+ Returns the safety backup ID, or None when password is None (backup intentionally
253
+ skipped). Raises ToolError if backup creation fails.
254
+ """
255
+ if password is None:
256
+ return None
257
+
258
+ now = datetime.now()
259
+ safety_backup_name = f"PreRestore_Safety_{now.strftime('%Y-%m-%d_%H:%M:%S')}"
260
+
261
+ safety_backup = await ws_client.send_command(
262
+ "backup/generate",
263
+ name=safety_backup_name,
264
+ password=password,
265
+ agent_ids=["hassio.local"],
266
+ include_homeassistant=True,
267
+ include_database=True,
268
+ include_all_addons=True,
269
+ )
270
+
271
+ if not safety_backup.get("success"):
272
+ raise_tool_error(create_error_response(
273
+ ErrorCode.SERVICE_CALL_FAILED,
274
+ safety_backup.get("error", "Failed to create safety backup before restore"),
275
+ suggestions=["Cannot proceed with restore without safety backup"],
276
+ ))
277
+
278
+ safety_backup_id = safety_backup.get("result", {}).get("backup_job_id")
279
+ logger.info(f"Safety backup created: {safety_backup_id}")
280
+ return cast(str, safety_backup_id)
281
+
282
+
232
283
  async def restore_backup(
233
284
  client: HomeAssistantClient, backup_id: str, restore_database: bool = False
234
285
  ) -> dict[str, Any]:
@@ -277,38 +328,14 @@ async def restore_backup(
277
328
 
278
329
  # Create safety backup BEFORE restoring
279
330
  logger.info("Creating safety backup before restore...")
280
- now = datetime.now()
281
- safety_backup_name = f"PreRestore_Safety_{now.strftime('%Y-%m-%d_%H:%M:%S')}"
282
-
283
- # Get backup password
284
331
  try:
285
332
  password = await _get_backup_password(ws_client)
286
333
  except ToolError:
287
334
  # Password error - log warning but continue (restore might still work)
288
335
  logger.warning("No default password - proceeding without safety backup")
289
336
  password = None
290
- safety_backup_id = None
291
-
292
- if password is not None:
293
- safety_backup = await ws_client.send_command(
294
- "backup/generate",
295
- name=safety_backup_name,
296
- password=password,
297
- agent_ids=["hassio.local"],
298
- include_homeassistant=True,
299
- include_database=True, # Full backup for safety
300
- include_all_addons=True,
301
- )
302
-
303
- if not safety_backup.get("success"):
304
- raise_tool_error(create_error_response(
305
- ErrorCode.SERVICE_CALL_FAILED,
306
- safety_backup.get("error", "Failed to create safety backup before restore"),
307
- suggestions=["Cannot proceed with restore without safety backup"],
308
- ))
309
337
 
310
- safety_backup_id = safety_backup.get("result", {}).get("backup_job_id")
311
- logger.info(f"Safety backup created: {safety_backup_id}")
338
+ safety_backup_id = await _create_safety_backup(ws_client, password)
312
339
 
313
340
  # Perform restore
314
341
  restore_params = {
@@ -216,6 +216,27 @@ def _check_template_string(
216
216
  # ---------------------------------------------------------------------------
217
217
 
218
218
 
219
+ def _check_choose_actions(
220
+ choose: Any, warnings: list[str], skill_prefix: str | None
221
+ ) -> None:
222
+ for option in _as_list(choose):
223
+ if isinstance(option, dict):
224
+ _check_condition_templates(
225
+ option.get("conditions", []), warnings, skill_prefix
226
+ )
227
+ _check_action_tree(
228
+ option.get("sequence", []), warnings, skill_prefix
229
+ )
230
+
231
+
232
+ def _check_repeat_actions(
233
+ repeat: dict, warnings: list[str], skill_prefix: str | None
234
+ ) -> None:
235
+ _check_condition_templates(repeat.get("while", []), warnings, skill_prefix)
236
+ _check_condition_templates(repeat.get("until", []), warnings, skill_prefix)
237
+ _check_action_tree(repeat.get("sequence", []), warnings, skill_prefix)
238
+
239
+
219
240
  def _check_action_tree(
220
241
  actions: Any, warnings: list[str], skill_prefix: str | None
221
242
  ) -> None:
@@ -235,14 +256,7 @@ def _check_action_tree(
235
256
 
236
257
  # Nested conditions in choose/if/repeat
237
258
  if "choose" in action:
238
- for option in _as_list(action["choose"]):
239
- if isinstance(option, dict):
240
- _check_condition_templates(
241
- option.get("conditions", []), warnings, skill_prefix
242
- )
243
- _check_action_tree(
244
- option.get("sequence", []), warnings, skill_prefix
245
- )
259
+ _check_choose_actions(action["choose"], warnings, skill_prefix)
246
260
 
247
261
  if "if" in action:
248
262
  _check_condition_templates(action["if"], warnings, skill_prefix)
@@ -253,16 +267,7 @@ def _check_action_tree(
253
267
  _check_action_tree(nested, warnings, skill_prefix)
254
268
 
255
269
  if "repeat" in action and isinstance(action["repeat"], dict):
256
- repeat = action["repeat"]
257
- _check_condition_templates(
258
- repeat.get("while", []), warnings, skill_prefix
259
- )
260
- _check_condition_templates(
261
- repeat.get("until", []), warnings, skill_prefix
262
- )
263
- _check_action_tree(
264
- repeat.get("sequence", []), warnings, skill_prefix
265
- )
270
+ _check_repeat_actions(action["repeat"], warnings, skill_prefix)
266
271
 
267
272
 
268
273
  # ---------------------------------------------------------------------------
@@ -347,6 +352,20 @@ def _check_mode_motion(
347
352
  )
348
353
 
349
354
 
355
+ def _has_delay_or_wait_in_nested(action: dict) -> bool:
356
+ for key in ("then", "else", "default", "sequence"):
357
+ if key in action and _has_delay_or_wait(action[key]):
358
+ return True
359
+ if "choose" in action:
360
+ for opt in _as_list(action["choose"]):
361
+ if isinstance(opt, dict) and _has_delay_or_wait(opt.get("sequence", [])):
362
+ return True
363
+ if "repeat" in action and isinstance(action["repeat"], dict):
364
+ if _has_delay_or_wait(action["repeat"].get("sequence", [])):
365
+ return True
366
+ return False
367
+
368
+
350
369
  def _has_delay_or_wait(actions: Any) -> bool:
351
370
  """Recursively check if any action uses delay or wait."""
352
371
  for action in _as_list(actions):
@@ -354,18 +373,8 @@ def _has_delay_or_wait(actions: Any) -> bool:
354
373
  continue
355
374
  if any(k in action for k in ("delay", "wait_for_trigger", "wait_template")):
356
375
  return True
357
- for key in ("then", "else", "default", "sequence"):
358
- if key in action and _has_delay_or_wait(action[key]):
359
- return True
360
- if "choose" in action:
361
- for opt in _as_list(action["choose"]):
362
- if isinstance(opt, dict) and _has_delay_or_wait(
363
- opt.get("sequence", [])
364
- ):
365
- return True
366
- if "repeat" in action and isinstance(action["repeat"], dict):
367
- if _has_delay_or_wait(action["repeat"].get("sequence", [])):
368
- return True
376
+ if _has_delay_or_wait_in_nested(action):
377
+ return True
369
378
  return False
370
379
 
371
380